Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion api/mcp-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@
},
{
"name": "delegate",
"description": "Create a durable task and scoped attempt. Reuse request_id for lost or uncertain responses. After a confirmed terminal blocked create, use a NEW request_id for a new create; reusing the old id returns the blocked operation. Role and tool guidance prefixes the brief.",
"description": "Create a durable task and scoped attempt. Reuse request_id for lost or uncertain responses. After a confirmed terminal blocked create, use a NEW request_id for a new create; reusing the old id returns the blocked operation. Role and tool guidance prefixes the brief. Choosing model and effort: match the tier to the task's scope, risk and reversibility, not habit. Mechanical or bounded, easily-reversible work takes the cheapest model and lowest effort likely to succeed; design, debugging, security-sensitive review, or anything hard to undo takes the strongest model and highest effort the profile offers. When re-delegating a failed attempt, prefer a higher tier over repeating the same one. Read the resolved provider profile's own models and effort_levels (see the providers list) rather than assuming any provider's names; leaving both unset keeps the profile's defaults.",
"input_schema": {
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -361,10 +361,34 @@
],
"default": null
},
"effort": {
"anyOf": [
{
"enum": ["minimal", "low", "medium", "high", "xhigh", "max"],
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"mode": {
"enum": ["code", "coordination"],
"type": "string"
},
"model": {
"anyOf": [
{
"pattern": "^[A-Za-z0-9._:/-]{1,200}$",
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"project_id": {
"anyOf": [
{
Expand Down
61 changes: 61 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6636,6 +6636,23 @@ components:
pattern: ^[a-z][a-z0-9_-]*$
- type: 'null'
title: Provider Profile Id
model:
anyOf:
- type: string
pattern: ^[A-Za-z0-9._:/-]{1,200}$
- type: 'null'
title: Model
description: >-
One of the resolved provider profile's models. Unset keeps the profile's
default_model. 422 with the allowed models if the profile does not offer it.
effort:
anyOf:
- $ref: '#/components/schemas/EffortLevel'
- type: 'null'
title: Effort
description: >-
One of the resolved provider profile's effort_levels. Unset keeps the profile's
default_effort. 422 with the allowed levels if the profile does not offer it.
checkout:
anyOf:
- $ref: '#/components/schemas/TaskCheckout'
Expand Down Expand Up @@ -7002,6 +7019,16 @@ components:
recovery:
type: string
description: 'parent_capacity only: how to free capacity.'
allowed_models:
type: array
items:
type: string
description: "model_unavailable only: the resolved profile's offered model ids."
allowed_effort_levels:
type: array
items:
$ref: '#/components/schemas/EffortLevel'
description: "effort_unavailable only: the resolved profile's offered effort levels."
required:
- reason
additionalProperties: true
Expand Down Expand Up @@ -7975,6 +8002,23 @@ components:
pattern: ^[a-z][a-z0-9_-]*$
title: Assigned Profile Id
type: string
model:
anyOf:
- type: string
- type: 'null'
default: null
title: Model
description: >-
The model the create request chose, validated against assigned_profile_id. Null
means the profile's default_model.
effort:
anyOf:
- $ref: '#/components/schemas/EffortLevel'
- type: 'null'
default: null
description: >-
The reasoning effort the create request chose, validated against
assigned_profile_id. Null means the profile's default_effort.
selection_source:
enum:
- explicit
Expand Down Expand Up @@ -8068,6 +8112,8 @@ components:
- brief
- mode
- assigned_profile_id
- model
- effort
- selection_source
- created_at
- updated_at
Expand Down Expand Up @@ -8153,6 +8199,19 @@ components:
configuration_revision:
title: Configuration Revision
type: string
model:
anyOf:
- type: string
- type: 'null'
default: null
title: Model
description: The model this attempt started with, or null for the profile's default.
effort:
anyOf:
- $ref: '#/components/schemas/EffortLevel'
- type: 'null'
default: null
description: The reasoning effort this attempt started with, or null for the profile's default.
agent_ref:
$ref: '#/components/schemas/ProviderAgentRef'
role:
Expand Down Expand Up @@ -8321,6 +8380,8 @@ components:
- profile_id
- native_provider
- configuration_revision
- model
- effort
- agent_ref
- role
- depth
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Every session — the main thread and a session's own chat alike, since both are

The wire routes are `POST /sessions/{session_id}/model` (`{"model": "<name>"|null}`) and `POST /sessions/{session_id}/effort` (`{"effort": "<level>"|null}`), both `200` with the session's resulting `{session_id, model, effort}`, `404` for a session that is not the owner's, and `422` for an effort level the provider does not offer. They apply equally to the main thread (its `session_id` is `GET /main-thread`'s own) and to any other session.

A task-owned session's override may already be set when the composer first shows it: `TaskCreate`/`delegate`'s optional `model` and `effort` (#318) apply as this same override before the attempt's first brief, not through these routes after the fact — see [delegation](delegation.md#choosing-a-workers-model-and-effort-318).

The A2A message that starts a turn carries the override as metadata, keyed `kagent.dev/model` and `kagent.dev/effort`; `server/internal/kagent` places it on the message `server/internal/sessions` builds for `SendMessage`, without changing its no-replay or `OutcomeUnknown` behavior. For Claude, only an actually-overridden key is sent; a key left at its default is omitted, since absent already means kagent's own harness default and nothing in Claude's native session retains a stale value for an absent key to clear. Codex is the one exception: its harness keeps the last explicit value as a turn/start override for its _later_ turns too, so once a session has had any override (ever, even if later reset), every turn for that session names both keys explicitly from then on — and resetting to "default" sends the profile's own real default model and effort values explicitly, not an empty value, because an empty value does not clear Codex's sticky state, only a concrete one does. A value kagent rejects (an unknown model, most often, since effort is pre-validated) is refused synchronously before the turn starts; this reads as a plain A2A error, shown on the delivery as "send rejected: …", the same clear per-message failure a bad prompt gets — never folded into the generic "could not prepare the kagent session" configuration-failure bucket.

## Sending messages
Expand Down
10 changes: 9 additions & 1 deletion docs/specs/delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ Mainloop's MCP tools create and read durable tasks through the same application

Main at depth zero creates supervisors. A supervisor at depth one creates direct children within its persisted project and task tree. Children at depth two and ordinary owner workspace agents cannot delegate. Discovery and direct invocation use the same exact role, depth and grant rules; each task call revalidates persisted identity and current ancestry. Missing, superseded, revoked, archived or terminal attempts cannot authenticate.

`delegate` accepts `request_id`, `title`, `brief`, `mode`, optional `project_id`, `topic_id`, `provider_profile_id` and typed `checkout`. Code tasks require an owned project and checkout; coordination tasks have no repository authority. The service chooses role, depth, owner, root and native configuration. Provider constraints and admission caps apply through the shared service. Reusing a request ID with identical input returns the original operation; changed input conflicts. A returned pending operation is not a running native session.
`delegate` accepts `request_id`, `title`, `brief`, `mode`, optional `project_id`, `topic_id`, `provider_profile_id`, `model`, `effort` and typed `checkout`. Code tasks require an owned project and checkout; coordination tasks have no repository authority. The service chooses role, depth, owner, root and native configuration. Provider constraints and admission caps apply through the shared service. Reusing a request ID with identical input returns the original operation; changed input conflicts. A returned pending operation is not a running native session.

Reuse the request ID after a lost or uncertain response. After a confirmed terminal `blocked` create, a new create requires a **new** request ID; replaying the old ID returns the recorded blocked operation.

### Choosing a worker's model and effort (#318)

`TaskCreate` and `delegate` take optional `model` and `effort`, so the supervisor — the main thread or any agent that calls `delegate` — can fit a worker's cost and capability to the work, rather than always starting it on its provider profile's `default_model` and `default_effort`. Both are validated at create time against the resolved profile (explicit `provider_profile_id`, else the project's or the installation's default): `model` against `ProviderProfile.models`, `effort` against `.effort_levels`. Either named but not offered answers `422` with a `TaskError` whose detail carries `allowed_models` or `allowed_effort_levels` (the profile's own list) alongside `reason` (`model_unavailable` or `effort_unavailable`); the `delegate` tool surfaces the same list in its failure text. Leaving a field unset is unchanged behavior: the attempt starts on the profile's own default, exactly as before this parameter existed.

A valid choice is recorded on the task and copied onto its attempt when the attempt is made, then applied as the new session's own `/model`/`/effort` override (`SetModelOverride`/`SetEffortOverride`, the same storage `PUT /sessions/{id}/model` and `/effort` use — see [sessions](sessions.md)) before the attempt's first brief is sent, never by a call after the fact that could race the first turn. A resume or a re-delegation that does not name `model`/`effort` keeps the attempt's own values; naming either overrides it for the new attempt.

The `delegate` tool's description carries short, provider-neutral rules for the choice: weigh the task's scope, risk and reversibility, and whether it is mechanical or needs judgment; default to the cheapest model and lowest effort likely to succeed; prefer a higher tier over repeating the same one when re-delegating a failure. It names no provider's models, pointing instead at the resolved profile's own `models`/`effort_levels` (`GET /providers`, see [provider profiles](providers.md)). Per-project defaults or limits (for example a maximum effort) are not in this slice.

Main's standing context lists its owner's projects with IDs, full repository names and whether an environment is selected. Selection does not guarantee environment readiness. This index is a snapshot when standing context is installed, not a live project query, and is not included for delegated agents.

Main's standing context also explains that tasks created directly by the owner in the app or through the owner API are owner-authored and legitimate; main reads them with `task_get`.
Expand Down
1 change: 1 addition & 0 deletions docs/specs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ History before `e776537` describes the retired Python backend. Where other text
- Delivery states in the Go ledger: `queued`, `recorded`, `sending`, `delivered`, `completed`, `failed`, `cancelled`, `uncertain`. Only `recorded` is ever sent: the claimer commits `sending` before the kagent call, holds no database lock or transaction across it, and fences every outcome write on its lease and attempt count, so a claimer that lost its lease changes nothing. `queued` is a message written while another turn was already open; the partial unique index that enforces one open turn per session (`recorded`, `sending`, `delivered`) excludes it, so any number of messages may wait their turn, each in the order it arrived. Whenever a turn ends — settled by the delivery loop, or stopped by the owner — the oldest message still `queued` for that session, if any, is admitted (moved to `recorded`) in the same transaction, one at a time. A session cancelled outright (not just one turn stopped) cancels every message still `queued` behind it instead: there is no next turn for them to wait for. This `queued` is the sessions ledger's own state, distinct from the task report queue's `queued` (`task_event_deliveries.state`, see [tasks](tasks.md#implemented-reports-to-parents-task-event-deliveries-loop)), which now simply admits at once instead of polling `ErrBusy`.
- kagent is retried only when it says the message was not accepted, with the same message id and inside the send retry budget (the kagent client does this). Every other doubt makes the delivery `uncertain`, and it is settled by looking the message up (`GetTask`, else `ListTasks`), never by sending again. A `sending` row reclaimed after a crash goes straight to `uncertain` and is looked up. If no task shows the message once the hold has passed, the delivery fails with "not sent again" and the owner may send a new message.
- Before a send, the loop makes the binding's kagent Session usable: it creates it on first use (idempotent by request id), resumes a suspended one, and waits for it to be ready (`SESSION_READY_WAIT_SECONDS`, 60 by default). When kagent no longer has the Session (its idle TTL, or deleted out of band) the binding gets a new create request id in a write fenced on the delivery's claim, and a new Session is created (the binding's standing hash is cleared, so the new Session's first message carries the recent conversation again, see the chat spec). A task parked for input or auth is read again at growing intervals up to `SESSION_PARKED_MAX_SECONDS` (300); a failed read backs off the same way. A delivery whose binding is gone fails. The binding records the create request id and the Agent before every create call (`agent_bindings.kagent_create_request_id`, `kagent_create_agent_namespace`/`_name`). They stay set once the Session id is stored and are cleared only when a create is settled or the Session is replaced, so every reader (the orphan sweep, the resolve sweep) also requires `kagent_session_id IS NULL`. A `resolve-session-creates` sweep (every 5 minutes) settles such a binding whose session has no open delivery: it first rotates the binding's `kagent_request_id` (one UPDATE, fenced on the binding still being unbound, the same create request id and no open delivery), so a message that arrives mid-sweep creates its own Session under the new id and the sweep only deletes the one its replay returns. It then replays the create under the old id with the Agent stored on the binding and the same credential reference the create sent. kagent answering that the id belongs to a deleted Session settles it; `AlreadyExists` (a contract mismatch) also settles it, with a warning, leaving the Session to the orphan sweep; a Session that comes back is deleted (confirmed `DELETED`) first. A later send then finds the id deleted and replaces it as above. A delivery stuck because its Agent cannot be resolved logs at error level once past the uncertain hold; one waiting for a workspace that never gets a Session fails after that hold. A workspace's Session is only ever resumed through the workspace service (`Wake`), never directly. A native or main session follows the configured Agent: a create under a new request id uses it, and so does a replacement Session. A configuration change between a lost create reply and the next send does not fail the message: the send replays the create under the old request id with the new Agent, kagent answers `AlreadyExists`, and the turn treats that like a deleted request id and replaces it, so the message completes on a new Session on the renamed Agent. The Session that landed on the old Agent is then an orphan: every Agent a create was ever sent to is kept (`kagent_agents`, written with the marker and with a workspace), so the orphan sweep deletes it.
- A task-owned session may start with its `/model` and `/effort` override already set (#318): `TaskCreate` and the `delegate` MCP tool take optional `model` and `effort`, validated against the resolved provider profile's `models`/`effort_levels` at task creation (`422` with the allowed values otherwise; see [delegation](delegation.md)), recorded on the task and its attempt, and applied with `SetModelOverride`/`SetEffortOverride` — the same storage and turn metadata as a direct `/model`/`/effort` write — before the attempt's first brief is sent, never after. Leaving both unset leaves the session's override unset too, unchanged from before #318. The chat composer and the `/model`/`/effort` commands ([chat](chat.md#the-model-and-effort-commands)) read and change the same override on a task-owned session as on any other.
- The main session cannot be archived (a database trigger refuses it). At most one main binding exists in the whole database; the owner is a single user for now, so the index is global.
- A task that finished while no process was watching is finalized from `GetTask` on restart. A task parked for input keeps the turn open.
- Failure reasons are stored with best-effort redaction and a 300 character cut (see [chat](chat.md)).
Expand Down
Loading
Loading