Просмотр исходного кода

Merge branch 'codex/simp-unify-agent-session-id' into codex/simp-ui-identity-residue

# Conflicts:
#	docs/event-producer-consumer.md
Tianyi Cui 2 месяцев назад
Родитель
Сommit
0d24cb14cb

+ 5 - 2
docs/architecture.md

@@ -51,14 +51,17 @@ Waterfall events behave like around-middleware: a listener delegates by calling
 
 ## Default Loop Lifecycle
 
-The shipped loop drains work, assembles requests, streams model answers, executes tools, applies continuation policy, and checkpoints state. Every pause is a service call or event available to plugins.
+Default loop processing remains exposed through plugin-visible services and events.
 
 A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points.
 
+Declarative startup chooses one agent/session identity. No id mints `<config-id>-session-<uuid>`; exact `sessionId` resumes when stored and otherwise creates; `resumeSessionId` requires stored history. Failures emit `agent-loop/config-start-failed(sessionId, error)`, letting front doors reject buffered work.
+
 ### Turn Flow
 
 ```text
-prepare private session + agent.ctx -> await unpublished setup
+choose declarative identity and fresh/resume path
+  -> prepare private session + agent.ctx -> await unpublished setup
   -> enter session + agent -> session/created -> agent/created
   -> enable driving -> agent/session-start(source) -> start driver
 forever:

+ 1 - 1
docs/config-catalog.md

@@ -131,7 +131,7 @@ export interface Config {
 
 Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
 
-Source: [`packages/core/agent-loop/src/index.ts:333`](../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:344`](../packages/core/agent-loop/src/index.ts)
 
 ## `@deepseek-ai/dsh-bash-local`
 

+ 12 - 0
docs/cordis-catalog/events.md

@@ -175,6 +175,18 @@ Types: [Agent](../core-data-structures/core.md)
 
 Source: [`packages/core/agent/src/types.ts:576`](../../packages/core/agent/src/types.ts)
 
+## `agent-loop/*`
+
+### `agent-loop/config-start-failed` — emit
+
+A declarative agent entry failed before it could publish a live agent. Consumers that buffer work for the configured identity use this transient signal to reject that work instead of waiting forever.
+
+```ts cordis-catalog
+'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
+```
+
+Source: [`packages/core/agent-loop/src/index.ts:339`](../../packages/core/agent-loop/src/index.ts)
+
 ## `approval/*`
 
 ### `approval/request` — waterfall

+ 1 - 1
docs/cordis-catalog/services.md

@@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<Agent
 async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
 ```
 
-Source: [`packages/core/agent-loop/src/index.ts:348`](../../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:359`](../../packages/core/agent-loop/src/index.ts)
 
 ## `ctx.agents` — `AgentRegistry`
 

+ 1 - 0
docs/event-producer-consumer.md

@@ -7,6 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
+| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:339`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
 | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
 | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
 | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |

+ 2 - 1
docs/module-graph.md

@@ -317,6 +317,7 @@ flowchart TD
   pkg_acp_agent --> pkg_user_interaction
   pkg_stdio_agent --> pkg_agent
   pkg_stdio_agent --> pkg_agent_core
+  pkg_stdio_agent --> pkg_agent_loop
   pkg_stdio_agent --> pkg_app_boot
   pkg_stdio_agent --> pkg_llm
   pkg_stdio_agent --> pkg_session
@@ -395,4 +396,4 @@ flowchart TD
 | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
 | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
 | [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
-| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
+| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`agent-loop`](../packages/core/agent-loop), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |

+ 6 - 0
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -238,6 +238,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
 
 /** Every harness event, sorted by name. */
 export const EVENT_API: readonly EventApiEntry[] = [
+  {
+    name: 'agent-loop/config-start-failed',
+    mode: 'emit',
+    signature: '\'agent-loop/config-start-failed\'(sessionId: SessionId, error: unknown): void',
+    summary: 'A declarative agent entry failed before it could publish a live agent.',
+  },
   {
     name: 'agent/created',
     mode: 'emit',

+ 1 - 1
packages/core/agent-loop/README.md

@@ -41,7 +41,7 @@ interface Config {
 }
 ```
 
-Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
+Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. A declarative lookup, resume, setup, or publication failure is contained, logged, and emitted as `agent-loop/config-start-failed(sessionId, error)` because no live `Agent` exists for an `agent/*` signal. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
 
 ### Exported concrete class
 

+ 28 - 2
packages/core/agent-loop/src/index.ts

@@ -327,6 +327,17 @@ declare module 'cordis' {
   interface Context {
     agentLoop: AgentLoop
   }
+  interface Events {
+    /**
+     * A declarative agent entry failed before it could publish a live agent.
+     * Consumers that buffer work for the configured identity use this
+     * transient signal to reject that work instead of waiting forever.
+     * @param sessionId - exact shared agent/session identity that failed startup.
+     * @param error - persistence, setup, or publication failure.
+     * @mode emit
+     */
+    'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
+  }
 }
 
 /** Plugin configuration for declarative startup agents. */
@@ -381,7 +392,7 @@ export class AgentLoop extends Service implements AgentFactory {
           this.create(configuredId, options, meta)
         } else {
           const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
-            ctx.logger.warn(`agent "${id}": config-driven restore of "${configuredId}" failed: ${String(error)}`)
+            this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
           })
           this.ownership.trackStartup(startup)
         }
@@ -396,7 +407,7 @@ export class AgentLoop extends Service implements AgentFactory {
             resumeSessionId,
             agentOptions: options,
           }).catch((error: unknown) => {
-            ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
+            this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
           })
         })
         return fiber.dispose
@@ -404,6 +415,21 @@ export class AgentLoop extends Service implements AgentFactory {
     }
   }
 
+  /** Report a contained declarative-start failure to identity-bound consumers. */
+  private reportConfiguredStartupFailure(
+    configId: string,
+    action: 'restore' | 'resume',
+    sessionId: SessionId,
+    error: unknown,
+  ): void {
+    this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${String(error)}`)
+    try {
+      this.ctx.emit('agent-loop/config-start-failed', sessionId, error)
+    } catch (listenerError) {
+      this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${String(listenerError)}`)
+    }
+  }
+
   /** Restore a materialized exact config identity on remount, or create it on first use. */
   private async restoreOrCreateConfigured(
     ownerCtx: Context,

+ 10 - 0
packages/core/agent-loop/tests/config-session-id.spec.ts

@@ -98,6 +98,12 @@ describe('config-driven session id', () => {
     const ctx = await makeCoreContext()
     await ctx.plugin(SessionPersistenceJsonl, { root })
     const failure = new Error('persistence index failed')
+    const listenerFailure = new Error('failure observer failed')
+    const failures: { sessionId: SessionId; error: unknown }[] = []
+    ctx.on('agent-loop/config-start-failed', (sessionId, error) => {
+      failures.push({ sessionId, error })
+    })
+    ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure })
     vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure)
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
 
@@ -108,6 +114,10 @@ describe('config-driven session id', () => {
     await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining(
       'config-driven restore of "stdio-exact-failure" failed: Error: persistence index failed',
     ))
+    expect(failures).toEqual([{ sessionId: SessionId('stdio-exact-failure'), error: failure }])
+    expect(warn).toHaveBeenCalledWith(
+      'agent "main": config-start-failed listener threw: Error: failure observer failed',
+    )
     expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined()
     warn.mockRestore()
     await ctx.fiber.dispose()

+ 1 - 1
packages/ui/stdio-agent/README.md

@@ -32,7 +32,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
 | `welcome` | `ready.` | the stdin-chat banner |
 | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
 
-Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. Readline buffers nonblank startup input for that identity until `agent/session-start`, so piped stdin cannot outrun asynchronous exact-id restoration or let EOF discard the queued prompt. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header.
+Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. Readline buffers nonblank startup input for that identity until `agent/session-start`, so piped stdin cannot outrun asynchronous exact-id restoration or let EOF discard the queued prompt; `agent-loop/config-start-failed` instead drains and reports buffered input so a missing or corrupt persisted session cannot hang EOF. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header.
 
 ## The bin
 

+ 2 - 0
packages/ui/stdio-agent/package.json

@@ -35,6 +35,7 @@
     "@deepseek-ai/dsh-app-boot": "^0.0.1",
     "@cordisjs/plugin-logger-console": "^1.0.0",
     "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-agent-loop": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-agent-core": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
@@ -51,6 +52,7 @@
     "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@cordisjs/plugin-logger-console": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-agent-loop": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-agent-core": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",

+ 12 - 11
packages/ui/stdio-agent/src/index.ts

@@ -102,16 +102,23 @@ export const Config: z<Config> = z.object({
 })
 
 /**
- * Compose the spine with the stdio front door. The console logger comes first
- * (infra), then the agent-core bundle pre-creating one agent from this app's
- * `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
- * backend, then the readline UI rendering that object as `main`. The `hmr` dev-reload plugin is
- * a leaf concern (see the module doc), so it is not mounted here.
+ * Compose the spine with the stdio front door. Console logging, persistence,
+ * and user interaction mount first; the readline UI then waits on the agent
+ * registry and subscribes to config-start failures before agent-core can start
+ * the configured identity. The ask-user tool waits on the completed spine.
+ * The `hmr` dev-reload plugin is a leaf concern (see the module doc), so it is
+ * not mounted here.
  */
 export function apply(ctx: Context, config: Config): void {
   const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
   const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
   ctx.plugin(ConsoleExporter)
+  ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
+  ctx.plugin(UserInteractionService)
+  ctx.plugin(uiStdio, {
+    welcome: config.welcome ?? 'ready.',
+    sessionId,
+  })
   ctx.plugin(agentCore, {
     ...config.persona !== undefined ? { persona: config.persona } : {},
     ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
@@ -124,11 +131,5 @@ export function apply(ctx: Context, config: Config): void {
     }],
     ...config.skills !== undefined ? { skills: config.skills } : {},
   })
-  ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
-  ctx.plugin(UserInteractionService)
   ctx.plugin(toolAskUser)
-  ctx.plugin(uiStdio, {
-    welcome: config.welcome ?? 'ready.',
-    sessionId,
-  })
 }

+ 20 - 0
packages/ui/stdio-agent/src/stdio-chat.ts

@@ -21,6 +21,7 @@ import type { Readable, Writable } from 'node:stream'
 import type { Context } from 'cordis'
 import z from 'schemastery'
 import type { Agent } from '@deepseek-ai/dsh-agent'
+import type {} from '@deepseek-ai/dsh-agent-loop'
 import {
   UserInteractionError,
   type AskUserQuestionAnswer,
@@ -173,6 +174,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
     const queuedInput: string[] = []
     let targetReady = target !== undefined
     let hadReadyTarget = targetReady
+    let failedStartup: { error: unknown } | undefined
 
     const submit = (agent: Agent, text: string): void => {
       submittedWork = true
@@ -187,6 +189,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
       if (!matchesConfiguredIdentity(agent)) return
       target = agent
       targetReady = false
+      failedStartup = undefined
     })
     const disposeSessionStartListener = ctx.on('agent/session-start', (agent) => {
       if (agent !== target) return
@@ -220,6 +223,18 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
       exitTimer = setTimeout(() => { exit(0) }, 200)
     }
 
+    const disposeStartupFailedListener = ctx.on('agent-loop/config-start-failed', (sessionId, error) => {
+      if (sessionId !== config.sessionId || targetReady) return
+      failedStartup = { error }
+      const dropped = queuedInput.length
+      queuedInput.length = 0
+      submittedWork = sawRunning
+      if (dropped > 0) {
+        ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${String(error)}`)
+      }
+      maybeExit()
+    })
+
     const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
       if (subject !== target) return
       if (status === 'running') sawRunning = true
@@ -374,6 +389,10 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
       }
       const text = line.trim()
       if (!text) return
+      if (failedStartup !== undefined) {
+        ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${String(failedStartup.error)}`)
+        return
+      }
       const agent = target
       if (agent === undefined || !targetReady) {
         // Initial exact-id restoration is asynchronous. Preserve input until
@@ -407,6 +426,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
       disposeCreatedListener()
       disposeSessionStartListener()
       disposeDisposedListener()
+      disposeStartupFailedListener()
       reader.close()
     }
   }, 'ui-stdio')

+ 46 - 1
packages/ui/stdio-agent/tests/stdio-chat.spec.ts

@@ -4,7 +4,7 @@ import { Context } from 'cordis'
 import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
-import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts'
 
@@ -750,6 +750,32 @@ describe('createStdioChat input', () => {
     expect(agent.sent).toEqual([[{ type: 'text', text: 'nobody home' }]])
   })
 
+  it('drops later input after the configured startup fails', async () => {
+    const { ctx, input } = await setup()
+    const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
+    const failure = new Error('persisted session is corrupt')
+    ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure)
+
+    input.feed('cannot run')
+    await new Promise(r => setImmediate(r))
+
+    expect(error).toHaveBeenCalledWith(
+      'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): Error: persisted session is corrupt',
+    )
+  })
+
+  it('ignores a stale config-start failure after the exact target is ready', async () => {
+    const { ctx, input } = await setup()
+    const agent = makeAgent('main')
+    registerReady(ctx, agent)
+    ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('stale'))
+
+    input.feed('still live')
+    await new Promise(r => setImmediate(r))
+
+    expect(agent.sent).toEqual([[{ type: 'text', text: 'still live' }]])
+  })
+
   it('drives the exact app-configured resumed session', async () => {
     const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' })
     const agent = makeAgent('worker')
@@ -808,6 +834,25 @@ describe('createStdioChat EOF exit', () => {
     expect(exit).toHaveBeenCalledWith(0)
   })
 
+  it('drains buffered piped input and exits when configured startup fails', async () => {
+    const { ctx, input, exit } = await setup()
+    const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
+    input.feed('work')
+    input.finish()
+    await new Promise(r => setImmediate(r))
+    ctx.emit('agent-loop/config-start-failed', SessionId('other'), new Error('unrelated'))
+    await flushExit()
+    expect(exit).not.toHaveBeenCalled()
+
+    ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('missing persisted session'))
+    await flushExit()
+
+    expect(error).toHaveBeenCalledWith(
+      'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): Error: missing persisted session',
+    )
+    expect(exit).toHaveBeenCalledWith(0)
+  })
+
   it('schedules the exit only once when idle fires repeatedly', async () => {
     const { ctx, input, exit } = await setup()
     const agent = makeAgent('main', 'running')

+ 3 - 0
pnpm-lock.yaml

@@ -1331,6 +1331,9 @@ importers:
       '@deepseek-ai/dsh-agent-core':
         specifier: workspace:^
         version: link:../../core/agent-core
+      '@deepseek-ai/dsh-agent-loop':
+        specifier: workspace:^
+        version: link:../../core/agent-loop
       '@deepseek-ai/dsh-app-boot':
         specifier: workspace:^
         version: link:../app-boot