Bladeren bron

fix: recognize provider-owned local subagents

Tianyi Cui 3 maanden geleden
bovenliggende
commit
cf21e252fa

+ 2 - 0
packages/subagent/subagent/README.md

@@ -50,6 +50,8 @@ Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a
 
 `SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
 
+A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, and records `request.parent.session.id` in the child's `parentSession` header. The child may be owned by the parent scope or by a provider/root scope; durable lineage is the transport-neutral local-child relation. Remote providers instead mint a parent-scoped lifecycle id without publishing a local child.
+
 The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. In-process start observers can resolve the published child through `ctx.agents.get(info.id)`; remote providers need not publish a local agent.
 
 Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.

+ 5 - 1
packages/subagent/subagent/src/types.ts

@@ -147,7 +147,11 @@ export interface SubagentResult {
  * presence of the method IS the capability — narrow before calling.
  */
 export interface SubagentRun {
-  /** Parent-scoped run id. Local runs use the published child session id; remote providers mint an id unique in the parent namespace. */
+  /**
+   * Parent-scoped run id. A local run publishes a child session whose
+   * `parentSession` records `request.parent`; a remote provider mints an id
+   * unique in the parent namespace.
+   */
   readonly id: SessionId
   /**
    * Resolves with the child's terminal {@link SubagentResult} when the run

+ 1 - 1
packages/ui/jsonrpc/README.md

@@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve
 
 ## Wiring
 
-`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server verifies that the live child is owned by the exact delegating parent, then counts local starts by provider/id and that parent carrier because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. The paired event carrier preserves parent correlation even when reused ids settle out of order. Runs from remote providers are not reported even when their parent-scoped run id collides with an unrelated local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`.
+`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server recognizes a live child through either exact delegating-parent runtime ownership or matching durable `parentSession` lineage, then counts local starts by provider/id and that parent carrier because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. The paired event carrier preserves parent correlation even when reused ids settle out of order. Runs from remote providers are not reported even when their parent-scoped run id collides with an unrelated local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`.
 
 ## Config
 

+ 16 - 6
packages/ui/jsonrpc/src/server.ts

@@ -66,6 +66,15 @@ function subagentParentOf(carrier: Scoped<SubagentService>): Agent {
   return carrierKeyOf(carrier) as Agent
 }
 
+/** Whether the live id names a local child related to this exact delegating parent. */
+function isLocalChild(ctx: Context, id: SessionId, parent: Agent): boolean {
+  const child = ctx.agents.get(id)
+  return child !== undefined && (
+    ctx.agents.isOwnedBy(id, parent)
+    || child.session.header.parentSession === parent.session.id
+  )
+}
+
 /**
  * The SDK server over a booted harness context. Constructing it subscribes to
  * session and subagent lifecycle events, forwarding durable session
@@ -104,13 +113,14 @@ export class HarnessSdkServer {
         childSessionId: String(session.id),
       })
     }))
-    // In-process providers publish the child before start. Count those starts by
-    // the exact delegating-parent carrier so later completions remain local after
-    // child disposal and reused ids need no settlement-order assumption.
+    // In-process providers publish the child before start. Count starts related
+    // by exact runtime ownership or durable parent lineage so provider-owned
+    // roots remain local, completions survive child disposal, and reused ids
+    // need no settlement-order assumption.
     const localRuns = this.localRuns
     this.disposers.push(ctx.on('subagent/start', function (this: Scoped<SubagentService>, info: SubagentRunInfo) {
       const parent = subagentParentOf(this)
-      if (!ctx.agents.isOwnedBy(info.id, parent)) return
+      if (!isLocalChild(ctx, info.id, parent)) return
       const providerRuns = localRuns.get(info.provider) ?? new Map<SessionId, Map<Agent, number>>()
       const parentRuns = providerRuns.get(info.id) ?? new Map<Agent, number>()
       parentRuns.set(parent, (parentRuns.get(parent) ?? 0) + 1)
@@ -130,9 +140,9 @@ export class HarnessSdkServer {
       }
       // This protocol reports LOCAL child sessions. A lineage-bearing child
       // has the session/created-driven start notification above. A remote run
-      // has neither a cached owned start nor a live child owned by this exact
+      // has neither a cached local start nor a live child related to this
       // parent; an unrelated local agent with the same id never makes it local.
-      if (pendingCount === undefined && !ctx.agents.isOwnedBy(info.id, parent)) return
+      if (pendingCount === undefined && !isLocalChild(ctx, info.id, parent)) return
       transport.notify('subagent.finished', {
         provider: info.provider,
         agentId: String(info.id),

+ 4 - 1
packages/ui/jsonrpc/tests/server.spec.ts

@@ -277,11 +277,14 @@ describe('HarnessSdkServer', () => {
         meta: { cwd: storageDir },
         agentOptions: { model: 'deepseek' },
       })
-      const handle = await parentHandle.agent.ctx.agents.create({
+      // A custom in-process provider may own its child at the provider/root
+      // scope while preserving durable parent lineage.
+      const handle = await ctx.agents.create({
         sessionId: SessionId('child-session'),
         meta: { cwd: storageDir, parentSession: SessionId('main') },
         agentOptions: { model: 'deepseek' },
       })
+      expect(ctx.agents.roots()).toContain(handle.agent)
       const parentlessHandle = await parentHandle.agent.ctx.agents.create({
         sessionId: SessionId('parentless-child-session'),
         meta: { cwd: storageDir },