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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,16 @@ several decisions below.
native derivation copies a historical parent deny but discards its later Build allow. The
resolved Plan agent alone is not read-only after project policy merges, so session-level Plan
enforcement remains required.
#75's asymmetry is fixed upstream in anomalyco/opencode#45064 (drop a copied deny when a
later rule supersedes it for the exact permission+pattern), and the reference deployment's
`ai.opencode.serve` LaunchAgent runs a patched binary
(`~/.opencode/bin/opencode-1.18.22-dca`, branch `v1.18.22-dca` in the local opencode
checkout = v1.18.22 + that commit; stock plist preserved as
`.state/launchd/ai.opencode.serve.plist.bak-stock-binary`). Verified live: a parent with
appended `[bash deny, bash allow]` spawned a task child with no inherited bash deny that ran
bash successfully. When the upstream fix ships in a release, bump the pin, point the plist
back at the stock binary, and re-run the probes. Session-level Plan enforcement is still
required regardless — the resolved Plan agent is not read-only after project merges.
20. **A file reference is data the server verified, never a URL the client trusted.**
The client contract is `WorkspaceTarget { path, startLine?, endLine? }`, not a route:
following a reference must not change the browser location, because the drawer is a
Expand Down
1 change: 1 addition & 0 deletions client/components/subagent-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ function TaskRow({
{task.agent && task.origin !== "managed-human" && <span data-testid="opencode-subagent-agent">agent: {task.agent}</span>}
{task.requestedAgent && <span data-testid="opencode-managed-child-requested-agent">agent: {task.requestedAgent}</span>}
{task.requestedModel && <span data-testid="opencode-subagent-requested-model">{task.requestedModel.providerID}/{task.requestedModel.modelID}{task.requestedModel.variant ? ` · ${task.requestedModel.variant}` : ""}</span>}
{!task.requestedModel && task.model && <span className="break-all" data-testid="opencode-subagent-model">{task.model.providerID}/{task.model.modelID}</span>}
{task.origin === "managed-human" && <span data-testid="opencode-subagent-policy-status">policy: {task.effectivePolicyObserved ? "verified at launch" : "unknown"}</span>}
{task.background && <span data-testid="opencode-subagent-background">background</span>}
{task.cost > 0 && <span className="tabular-nums">{formatCost(task.cost)}</span>}
Expand Down
2 changes: 2 additions & 0 deletions client/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export interface SubagentTask {
requestedMode?: AgentMode;
requestedAgent?: ManagedChildAgent;
requestedModel?: ModelSelection;
/** Model the task tool resolved for a native child; provenance only. */
model?: { providerID: string; modelID: string };
policySource?: "creation-permission";
effectivePolicyObserved?: boolean;
description?: string;
Expand Down
28 changes: 25 additions & 3 deletions docs/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,23 @@ Keep the live probe disposable and version-scoped when changing this boundary. D
requested mode is effective policy, and do not synthesize native hand-back behavior for managed
children.

## Model selection for delegated work

Managed Children accept an explicit, validated model at launch, and the ledger shows the
requested model as provenance. Native task children are different (issue #90): the task tool
has **no per-delegation model parameter**. Upstream resolves the child model as the subagent's
configured model when its agent definition pins one, otherwise the parent's current model at
delegation time. The two levers that exist today:

1. Pin a model on the agent definition (`opencode.json` `agent.<name>.model`) so every
delegation to that agent uses it.
2. Switch the parent's model before delegating; children of agents without a pinned model
inherit it.

The delegated-work panel shows the model each native child actually ran with, read from the
task part's launch metadata. That display is provenance, not a control — per-delegation
selection needs an upstream task-tool parameter that does not exist yet.

## Events, polling, and completion

The BFF owns one upstream `GET /global/event` connection. Unlike directory-scoped `/event`, the
Expand Down Expand Up @@ -325,9 +342,14 @@ session into that directory. Relative edits, default shell CWD, LSP, VCS, snapsh
and event envelopes remain scoped to the parent instance. A mutating child must therefore treat its
assigned absolute worktree path as a hard boundary.

Use a fresh Build-only parent for mutating children. During workflow validation, children launched
from a parent that had previously activated Plan retained terminal Bash denies even after Build made
the parent's own tools available again. Until child permission inheritance is fixed and verified,
On a **stock** OpenCode build, use a fresh Build-only parent for mutating children. During
workflow validation, children launched from a parent that had previously activated Plan retained
terminal Bash denies even after Build made the parent's own tools available again: stock
`deriveSubagentSessionPermission` copies every parent-session deny while discarding the later
allows that superseded them. An upstream fix (anomalyco/opencode#45064) copies only denies that
are still the parent's effective action for their exact permission and pattern; deployments
running a build with that fix verified live (the reference deployment runs `v1.18.22-dca`,
v1.18.22 plus that commit) no longer need the fresh-parent workaround. On a build without it,
failed preflight means stop; do not weaken policy or silently replace the native child with an
independent root session.

Expand Down
30 changes: 29 additions & 1 deletion server/opencode/subagents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export interface SubagentTask {
requestedMode?: "plan" | "build";
requestedAgent?: ManagedChildAgent;
requestedModel?: ModelSelection;
/**
* Model the task tool actually resolved for a native child (issue #90).
* Provenance, not a control: per-delegation model selection needs an
* upstream task-tool parameter that does not exist yet.
*/
model?: { providerID: string; modelID: string };
policySource?: "creation-permission";
effectivePolicyObserved?: boolean;
/** One-line delegation intent from the task tool input. */
Expand Down Expand Up @@ -144,6 +150,12 @@ export interface TaskLaunch {
error?: string;
launchedAt: number;
updatedAt: number;
/**
* Model the task tool resolved for the child (issue #90): the subagent's
* configured model, or the parent's model at delegation time. Provenance
* from launch metadata — the task tool offers no per-delegation override.
*/
model?: { providerID: string; modelID: string };
}

function text(value: unknown): string | undefined {
Expand Down Expand Up @@ -171,6 +183,14 @@ export function childSessionIdOf(part: RawPart): string | undefined {
return text(source.sessionId) ?? text(source.sessionID);
}

function launchModel(metadata: Record<string, unknown>): TaskLaunch["model"] {
const source = metadata.model;
if (!source || typeof source !== "object") return undefined;
const providerID = text((source as Record<string, unknown>).providerID);
const modelID = text((source as Record<string, unknown>).modelID);
return providerID && modelID ? { providerID, modelID } : undefined;
}

function launchStatus(raw: string | undefined): TaskLaunch["status"] {
switch (raw) {
case "pending":
Expand Down Expand Up @@ -220,6 +240,9 @@ export function collectTaskLaunches(messages: RawTranscriptMessage[]): TaskLaunc
error: text(state.error) ?? existing?.error,
launchedAt: existing ? Math.min(existing.launchedAt, at) : at,
updatedAt: existing ? Math.max(existing.updatedAt, updated) : updated,
...((launchModel(metadata) ?? existing?.model)
? { model: launchModel(metadata) ?? existing?.model }
: {}),
};
byChild.set(sessionID, launch);
}
Expand Down Expand Up @@ -398,7 +421,12 @@ export function deriveSubagentTasks(input: DeriveSubagentsInput): SubagentTask[]
policySource: child.managed.policySource,
effectivePolicyObserved: child.managed.effectivePolicyObserved,
}
: launch ? { origin: "native-task" as const } : {}),
: launch
? {
origin: "native-task" as const,
...(launch.model ? { model: launch.model } : {}),
}
: {}),
background: child?.managed?.background === true || launch?.background === true,
present: child !== undefined,
createdAt,
Expand Down
7 changes: 7 additions & 0 deletions tests/e2e/subagents.api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ interface Task {
present: boolean;
agent?: string;
description?: string;
origin?: string;
model?: { providerID: string; modelID: string };
}

test.describe("GET /api/sessions/:id/subagents", () => {
Expand All @@ -37,6 +39,11 @@ test.describe("GET /api/sessions/:id/subagents", () => {
expect(byId.size).toBe(6);
expect(byId.get(CHILD_RUNNING)).toMatchObject({ state: "running", evidence: "session-status" });
expect(byId.get(CHILD_DONE)).toMatchObject({ state: "completed", evidence: "child-transcript" });
// Native rows carry the model the task tool resolved, as provenance (#90).
expect(byId.get(CHILD_DONE)).toMatchObject({
origin: "native-task",
model: { providerID: "anthropic", modelID: "claude-opus-5" },
});
// Its own last turn never finished; only the parent's hand-back settles it.
expect(byId.get(CHILD_REPORTED)).toMatchObject({ state: "completed", evidence: "parent-completion" });
// The parent's task part reports "completed" for this one, but it was a
Expand Down
27 changes: 27 additions & 0 deletions tests/subagents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function taskMessage(
start?: number;
end?: number;
created?: number;
model?: Record<string, unknown>;
} = {},
): RawTranscriptMessage {
return {
Expand All @@ -44,6 +45,7 @@ function taskMessage(
metadata: {
sessionId: over.sessionId ?? CHILD,
...(over.background ? { background: true } : {}),
...(over.model ? { model: over.model } : {}),
},
time: { start: over.start ?? 1_000, end: over.end ?? 2_000 },
},
Expand Down Expand Up @@ -143,6 +145,19 @@ describe("collectTaskLaunches", () => {
expect(collectTaskLaunches([taskMessage({ input: { prompt: "Do the\nthing" } })])[0].description)
.toBe("Do the thing");
});

it("captures the resolved child model as provenance and keeps it across resume parts", () => {
const launches = collectTaskLaunches([
taskMessage({ status: "running", model: { providerID: "anthropic", modelID: "claude-opus-5" } }),
taskMessage({ status: "completed", start: 5_000, end: 6_000 }),
]);
expect(launches[0].model).toEqual({ providerID: "anthropic", modelID: "claude-opus-5" });
});

it("ignores malformed model metadata rather than guessing", () => {
expect(collectTaskLaunches([taskMessage({ model: { providerID: "anthropic" } })])[0].model).toBeUndefined();
expect(collectTaskLaunches([taskMessage({ model: { providerID: " ", modelID: "x" } })])[0].model).toBeUndefined();
});
});

describe("collectSyntheticOutcomes", () => {
Expand Down Expand Up @@ -196,6 +211,18 @@ describe("childTerminalState", () => {
});

describe("deriveSubagentTasks", () => {
it("carries the resolved model onto native task rows as provenance", () => {
const [task] = deriveSubagentTasks(deriveInput({
launches: [launch({ model: { providerID: "anthropic", modelID: "claude-opus-5" } })],
children: [child()],
childTerminals: new Map([[CHILD, { state: "completed" as const }]]),
}));
expect(task).toMatchObject({
origin: "native-task",
model: { providerID: "anthropic", modelID: "claude-opus-5" },
});
});

it("prefers observed liveness over every inference below it", () => {
const [task] = deriveSubagentTasks(deriveInput({
launches: [launch({ status: "completed" })],
Expand Down
Loading