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
98 changes: 87 additions & 11 deletions docs/features/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

## Summary

An agent is a saved worker you hand work to: its instructions, the model it
runs on, the tools it may use, a default permission and where it works. Assign
work to an agent and Stave makes the task — in a new worktree or in the current
workspace — and starts it. Every later turn of that task runs as the same
version of the agent.
An agent is who runs a task: its instructions, how it picks a model, the tools
it may use, a permission ceiling and where it works. Assign work to an agent
and Stave makes the task — in a new worktree or in the current workspace — and
starts it. Every later turn of that task runs as the same version of the agent.
A Worker is different: a helper a task's model hands one piece of work to
within a turn.

Saving an agent starts nothing and grants nothing. Each assignment records its
own start, and the permission you see is only the default the task starts with.
Expand Down Expand Up @@ -77,8 +78,11 @@ it runs as one task or a playbook mission, and the new worktree and its base.
- **Duplicate**, **Archive**, **Restore** and **Delete**. Built-in and
repository agents are read only; an update never overwrites your copy.
Archiving stops new assignments only.
- **Work**: the agent's assignments and their state: **Preparing**,
**Started**, **Couldn't start** or **Interrupted**.
- **Activity**, under the header: how many assignments the agent has, how
many of its tasks are running or need you, how many couldn't start, when it
was last used, and **Work** — its assignments with their state (**Preparing**,
**Started**, **Couldn't start** or **Interrupted**) and a filter.
- **Settings** and **History** tabs. History is covered below.

### Editing an agent

Expand All @@ -97,9 +101,39 @@ A field that fails a rule shows the reason inline (for example, a read-only
agent cannot take a new worktree, and a tool cannot be both allowed and denied).
A save blocked by an **Advanced** field opens **Advanced**.

### History

Every save that changes how a custom agent behaves keeps the version it
replaced; the last 10 are listed newest first. Each row names the fields that
differ from the agent now and shows **Ran** when a task used that exact
version. **Restore** saves the old version as the current agent, so the version
it replaces joins History and a restore can itself be undone. Archiving and
concurrency changes are not new versions.

### Learned suggestions

When you correct a custom agent in one of its tasks (you write again after it
answered), Stave asks the **Utility inference** model once whether the agent's
instructions should change so the correction is not needed next time. If so,
a suggestion appears on the agent's page with the change in one sentence:

- **Apply** saves the suggested instructions like an edit, so the previous
instructions stay in History.
- **Edit** lets you change the suggested instructions before applying them.
- **Dismiss** drops the suggestion.

Learning is on for new custom agents; turn **Learn from my corrections** off
on an agent's page to stop it. Each corrected task uses at most one request,
and an agent keeps at most three open suggestions. Only the task's messages
from you and the agent are sent — no tool output, attachments or secrets — and
the request runs read only. A suggestion written against instructions you have
since changed says so; applying it replaces them. Built-in and repository
agents do not learn.

### Deleting an agent

**Delete** removes a custom agent from your settings. Past assignments keep
**Delete** removes a custom agent from your settings, with its History and
suggestions. Past assignments keep
their own snapshot, so an agent's history still shows its name after it is gone.
If a playbook stage or a project still names the agent, deletion is blocked and
the dialog lists where — **Archive instead**, or remove those references first.
Expand All @@ -114,18 +148,57 @@ dot — a green dot for running, an amber dot when it needs you), in the Kickoff
**Flow** panel. Built-in and repository agents get a stable colour derived from
their id; a custom agent's colour is chosen in its **Profile** section.

### Agentic tasks (experimental)

By default a task runs on the model you pick in the composer. Turn on
**Settings → Chat → Agents → Run tasks as agents** to pick an agent there
too:

- The model picker gets an **Agents** tab above the providers. It lists
**No agent** — the picked model runs the task with its own permissions —
and every agent that can run a task.
- While an agent runs the task, the picker's button shows the agent's avatar
and name before the model.
- A choice applies from the next turn and stays until you change it. Earlier
turns keep the agent they ran as, and each agent's History counts the turns
it ran. Choosing **No agent** ends the agent for this task. The choice is
locked while a turn runs or waits for an answer.
- A switch that lets the task do more — to an agent with a wider permission,
or from a limited agent back to **No agent** — asks first.
- An agent with a fixed model moves the picker to that model. Mid-task it does
so only on the same provider; pick another provider yourself. A model picked
on a provider tab overrides the agent's model for the task.
- A task that runs as an agent has no Worker. The agent hands work to other
agents itself through delegation. With **No agent** the Worker is offered as
usual.

Turning the setting off hides the Agents tab. Tasks that already run as an
agent keep running as it.

### Usable as and the Worker picker

A custom agent's **Usable as** chooses where it can be used: **Main agent**
(Start work / Kickoff), **Worker** (the composer's Worker mode) and **Delegated
task**. Duplicate the built-in **Reviewer** and turn on **Main agent** to start
work with it directly.

Custom agents usable as a Worker appear under **Custom agents** in the
composer's Worker menu. Picking one copies its instructions and tool list into
Custom agents usable as a Worker appear under **Agents as Worker** in the
composer's Worker menu. A Worker agent takes on part of a turn while the task
keeps its own model; to have an agent run the whole task, use the model
picker's **Agents** tab. Picking one copies its instructions and tool list into
this task's Worker; edit the agent and pick it again to refresh the copy.
Picking a preset afterwards clears it.

### Can call

**Can call**, under Advanced, limits which agents a task running as this agent
may delegate to. **Any agent** (the default) sets no limit. **Only these
agents** offers every active agent usable as a delegated task; checking none
means the agent delegates to no one. A delegation to an agent outside the list
is refused with the names it may call. A project's Agents still apply on top:
a mission's agent may call only agents that are on both lists. Exported agent
files leave **Can call** out.

### My standards

**My standards**, its own tab on the Agents surface, holds your own rules for
Expand Down Expand Up @@ -222,7 +295,9 @@ Fleet's search finds its tasks.
- If Stave stops while preparing, the assignment shows **Interrupted** with
what it had made. It is never started again on its own.
- Cursor and Kiro receive the agent's instructions at the top of the first
message; Claude and Codex receive them on their instruction channel.
message — or of the next message after the task starts as, or switches to,
an agent from Kickoff, a mission or the composer. Claude and Codex receive
them on their instruction channel with every turn.
- An agent's permission is a ceiling, never a grant. Every turn of the task —
the first and each later one — keeps your permission settings where they are
already narrower and lowers them where they are wider:
Expand All @@ -247,6 +322,7 @@ Fleet's search finds its tasks.
- Hooks, inline MCP servers and approval-skipping modes in agent files are
never imported.
- A worktree is a separate checkout, not a sandbox.
- Running tasks as agents is experimental.

## Related

Expand Down
15 changes: 12 additions & 3 deletions electron/host-service/supervision/assign-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
* Used by: `electron/host-service.ts`.
*/
import type { AgentDelegationContext, AgentInvokeResult, HostAgentAction } from "../../../src/lib/agents/api";
import { RecordTaskAgentInputSchema } from "../../../src/lib/agents/assign";
import { RecordTaskAgentInputSchema, ReleaseTaskAgentInputSchema } from "../../../src/lib/agents/assign";
import { listAgents, normalizeCustomAgents } from "../../../src/lib/agents/library";
import { activeStandards, normalizeMyStandards } from "../../../src/lib/agents/standards";
import type { AgentConfig } from "../../../src/lib/agents/schema";
import { taskAgentRuntimeOptions } from "../../../src/lib/agents/runtime-options";
import { setTaskRuntimeOptionsResolver } from "../../providers/runtime";
import { setTaskPromptPrefixResolver, setTaskRuntimeOptionsResolver } from "../../providers/runtime";
import * as localMcpRuntime from "../local-mcp-runtime";
import { ensureHostServicePersistenceReady } from "../persistence";
import { runSupervisedTurn } from "../supervised-turn";
Expand Down Expand Up @@ -45,6 +45,9 @@ export function createHostAssignRuntime(args: {
? taskAgentRuntimeOptions({ agent: task.agent, providerId, base: runtimeOptions, standards: task.standards })
: {};
});
// A task recorded before its first turn, or switched to another agent,
// owes a prompt-channel provider the agent's instructions once.
setTaskPromptPrefixResolver(({ taskId, providerId }) => runtime.takeTaskPreamble(taskId, providerId));
},
};
}
Expand Down Expand Up @@ -98,6 +101,10 @@ export async function invokeAgentAction(
}),
};
}
case "release-task": {
const value = ReleaseTaskAgentInputSchema.parse(args);
return { ok: true, value: runtime.releaseTaskAgent(value.taskId) };
}
case "list-assignments": {
const value = (args ?? {}) as { agentConfigId?: string; limit?: number };
return { ok: true, value: runtime.list(value) };
Expand All @@ -110,8 +117,10 @@ export async function invokeAgentAction(
}
case "delegation-context": {
const taskId = String((args as { parentTaskId?: unknown } | null)?.parentTaskId ?? "");
const parent = runtime.agentForTask(taskId);
const value: AgentDelegationContext = {
parentPermission: runtime.agentForTask(taskId)?.permission ?? null,
parentPermission: parent?.permission ?? null,
parentCanCall: parent?.canCall ?? null,
allowedAgentIds: projectAgentsForTask(taskId),
};
return { ok: true, value };
Expand Down
45 changes: 43 additions & 2 deletions electron/host-service/supervision/assign-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ export interface AssignRuntime {
}) => AgentAssignment;
/** The agent and standards an assigned task runs with, for its later turns. */
taskAgent: (taskId: string) => { agent: AgentAssignment["agent"]; standards: string | null } | null;
/**
* Ends the task's current agent: its later turns run with the task's own
* settings. The row stays for history. Returns the ended row, or null when
* the task was not running as an agent.
*/
releaseTaskAgent: (taskId: string) => AgentAssignment | null;
/**
* The instructions a prompt-channel provider still owes the task's agent,
* taken once: the flag clears whether or not this provider needed them.
*/
takeTaskPreamble: (taskId: string, providerId: ProviderId) => string | null;
}

export function createAssignRuntime(deps: AssignRuntimeDependencies): AssignRuntime {
Expand All @@ -87,16 +98,43 @@ export function createAssignRuntime(deps: AssignRuntimeDependencies): AssignRunt
}
};

/** The row a task runs as now: its newest, unless the user ended it. */
const currentRow = (taskId: string) => {
const row = deps.store.getByTaskId(taskId);
return row && !row.endedAt ? row : null;
};

return {
list: (args = {}) => deps.store.list(args),
agentForTask: (taskId) => {
// A task intake made for an agent keeps running as it, even after a failed first turn.
return deps.store.getByTaskId(taskId)?.agent ?? null;
return currentRow(taskId)?.agent ?? null;
},
taskAgent: (taskId) => {
const row = deps.store.getByTaskId(taskId);
const row = currentRow(taskId);
return row ? { agent: row.agent, standards: row.standards ?? null } : null;
},
releaseTaskAgent(taskId) {
const row = currentRow(taskId);
if (!row) return null;
const timestamp = now().toISOString();
const ended: AgentAssignment = { ...row, endedAt: timestamp, preambleDue: false, updatedAt: timestamp };
deps.store.update(ended);
announce(ended);
return ended;
},
takeTaskPreamble(taskId, providerId) {
const row = currentRow(taskId);
if (!row?.preambleDue) return null;
deps.store.update({ ...row, preambleDue: false, updatedAt: now().toISOString() });
const compiled = compileAgent({
snapshot: snapshotAgent({ ...row.agent, archived: false }),
role: "primary",
providerId,
...(row.standards ? { standards: row.standards } : {}),
});
return compiled.ok && compiled.compiled.role === "primary" ? (compiled.compiled.promptPreamble ?? null) : null;
},
recordTaskAgent(args) {
const existing = deps.store.getByRequestId(args.requestId);
if (existing) return existing;
Expand Down Expand Up @@ -125,6 +163,9 @@ export function createAssignRuntime(deps: AssignRuntimeDependencies): AssignRunt
state: "started",
detail: null,
standards: args.standards ?? null,
// The starter sends the user's text as is, so a prompt-channel provider
// gets the agent's instructions from the next primary turn instead.
preambleDue: true,
received: compiled.compiled.received,
support: compiled.compiled.support,
createdAt: timestamp,
Expand Down
11 changes: 10 additions & 1 deletion electron/main/ipc/agents.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ipcMain, webContents } from "electron";
import { z } from "zod";
import { AGENT_IPC, type AgentInvokeResult, type HostAgentAction } from "../../../src/lib/agents/api";
import { AssignAgentInputSchema, RecordTaskAgentInputSchema } from "../../../src/lib/agents/assign";
import { AssignAgentInputSchema, RecordTaskAgentInputSchema, ReleaseTaskAgentInputSchema } from "../../../src/lib/agents/assign";
import { invokeHostService, onHostServiceEvent, onHostServiceReady } from "../host-service-client";
import { getCustomAgents, getMyStandards, setCustomAgents, setMyStandards } from "../agents/agent-registry";

Expand Down Expand Up @@ -68,6 +68,15 @@ export function registerAgentHandlers() {
return failed(error, "The task's agent could not be recorded.");
}
});
ipcMain.handle(AGENT_IPC.releaseTask, async (_event, args: unknown) => {
const parsed = ReleaseTaskAgentInputSchema.safeParse(args);
if (!parsed.success) return { ok: false, code: "invalid", message: "Invalid request." };
try {
return await invokeAgent("release-task", parsed.data);
} catch (error) {
return failed(error, "The task's agent could not be changed.");
}
});
ipcMain.handle(AGENT_IPC.sync, async (_event, args: unknown) => {
const parsed = SyncSchema.safeParse(args);
if (!parsed.success) return { ok: false };
Expand Down
1 change: 1 addition & 0 deletions electron/main/runs/delegated-task-coordinator-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ async function applyAgent(args: DelegateTaskArgs) {
agent,
parentPermission: context.value.parentPermission,
allowedAgentIds: context.value.allowedAgentIds,
parentCanCall: context.value.parentCanCall,
standards: activeStandards(getMyStandards()),
});
return result.ok
Expand Down
8 changes: 4 additions & 4 deletions electron/persistence/agent-assignment-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,20 @@ export class AgentAssignmentStore {
return parse(this.db.prepare(`SELECT body_json FROM agent_assignments WHERE request_id = ?`).get(requestId));
}

/** The assignment that created a task, for "which agent is this task". */
/** The newest assignment of a task: the agent it runs as now, unless that row has ended. */
getByTaskId(taskId: string): AgentAssignment | null {
return parse(
this.db.prepare(`SELECT body_json FROM agent_assignments WHERE task_id = ? ORDER BY created_at DESC LIMIT 1`).get(taskId),
this.db.prepare(`SELECT body_json FROM agent_assignments WHERE task_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`).get(taskId),
);
}

list(args: { agentConfigId?: string; limit?: number } = {}): AgentAssignment[] {
const limit = Math.max(1, Math.min(args.limit ?? 100, 500));
const rows = args.agentConfigId
? this.db
.prepare(`SELECT body_json FROM agent_assignments WHERE agent_config_id = ? ORDER BY created_at DESC LIMIT ?`)
.prepare(`SELECT body_json FROM agent_assignments WHERE agent_config_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?`)
.all(args.agentConfigId, limit)
: this.db.prepare(`SELECT body_json FROM agent_assignments ORDER BY created_at DESC LIMIT ?`).all(limit);
: this.db.prepare(`SELECT body_json FROM agent_assignments ORDER BY created_at DESC, rowid DESC LIMIT ?`).all(limit);
return rows.flatMap((row) => {
const parsed = parse(row);
return parsed ? [parsed] : [];
Expand Down
1 change: 1 addition & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,7 @@ ipcRenderer.on(AGENT_IPC.changed, () => {
const agentsApi: AgentsBridgeApi = {
assign: (args) => ipcRenderer.invoke(AGENT_IPC.assign, args),
recordTask: (args) => ipcRenderer.invoke(AGENT_IPC.recordTask, args),
releaseTask: (args) => ipcRenderer.invoke(AGENT_IPC.releaseTask, args),
listAssignments: (args) => ipcRenderer.invoke(AGENT_IPC.listAssignments, args ?? {}),
sync: (args) => ipcRenderer.invoke(AGENT_IPC.sync, args),
subscribeChanged: (listener) => {
Expand Down
28 changes: 27 additions & 1 deletion electron/providers/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,33 @@ export function setTaskRuntimeOptionsResolver(resolver: TaskRuntimeOptionsResolv
taskRuntimeOptionsResolver = resolver;
}

function withTaskRuntimeOptions<T extends StreamTurnArgs>(args: T): T {
/**
* Text a task still owes its provider at the start of the next primary turn
* (an agent's instructions for a provider that takes them in the prompt).
* Returning it consumes it.
*/
type TaskPromptPrefixResolver = (args: { taskId: string; providerId: StreamTurnArgs["providerId"] }) => string | null;

let taskPromptPrefixResolver: TaskPromptPrefixResolver | null = null;

export function setTaskPromptPrefixResolver(resolver: TaskPromptPrefixResolver | null) {
taskPromptPrefixResolver = resolver;
}

function withTaskPromptPrefix<T extends StreamTurnArgs>(args: T): T {
// Secondary read-only analysis turns never consume what the user's turn is owed.
if (!args.taskId || !taskPromptPrefixResolver || args.executionPolicy) return args;
let prefix: string | null = null;
try {
prefix = taskPromptPrefixResolver({ taskId: args.taskId, providerId: args.providerId });
} catch (error) {
console.warn("[provider] task prompt prefix lookup failed", error);
}
return prefix ? { ...args, prompt: `${prefix}\n\n---\n\n${args.prompt}` } : args;
}

function withTaskRuntimeOptions<T extends StreamTurnArgs>(rawArgs: T): T {
const args = withTaskPromptPrefix(rawArgs);
if (!args.taskId || !taskRuntimeOptionsResolver) return args;
let extra: Partial<NonNullable<StreamTurnArgs["runtimeOptions"]>> = {};
try {
Expand Down
Loading
Loading