fix(ui): surface declarative startup failures
This commit is contained in:
@@ -79,6 +79,6 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Tool calls within a step execute sequentially** — parallel execution waits on concurrency-safety metadata in the tool contract (see `dsh-tools`).
|
||||
- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-<uuid>` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent.
|
||||
- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-<uuid>` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning, emits and retains `agent/start-failed` while that declaration remains loaded, and creates no agent.
|
||||
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
|
||||
- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin.
|
||||
|
||||
@@ -363,18 +363,29 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
this.create(id, options, cwd === undefined ? {} : { cwd })
|
||||
continue
|
||||
}
|
||||
ctx.effect(() => {
|
||||
ctx.effect(function* (this: AgentLoop) {
|
||||
let active = true
|
||||
let releaseFailure = (): void => {}
|
||||
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
void this.resumeWith(ctx, childCtx.sessionPersistence, {
|
||||
agentId: id,
|
||||
resumeSessionId,
|
||||
agentOptions: options,
|
||||
}).catch((error: unknown) => {
|
||||
if (!active) return
|
||||
const failure = new Error(error instanceof Error ? error.message : String(error), { cause: error })
|
||||
ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
|
||||
releaseFailure = ctx.agents.reportStartFailure(id, failure)
|
||||
})
|
||||
})
|
||||
return fiber.dispose
|
||||
}, `agentLoop.resume(${id})`)
|
||||
yield fiber.dispose
|
||||
// Yielded last, disposed first: suppress teardown rejection before the
|
||||
// deferred persistence child wakes and clear any retained record.
|
||||
yield () => {
|
||||
active = false
|
||||
releaseFailure()
|
||||
}
|
||||
}.bind(this), `agentLoop.resume(${id})`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
@@ -42,6 +43,72 @@ describe('config-driven session id', () => {
|
||||
await loopFiber.dispose()
|
||||
})
|
||||
|
||||
it('drops an in-flight declarative resume when its owner is disposed', async () => {
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
const pendingLoad = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>()
|
||||
class DeferredSessionPersistence extends SessionPersistence {
|
||||
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
|
||||
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
|
||||
load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
loadStarted.resolve(undefined)
|
||||
return pendingLoad.promise
|
||||
}
|
||||
list(): Promise<SessionHeader[]> { return Promise.resolve([]) }
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const failures: Error[] = []
|
||||
ctx.on('agent/start-failed', (_id, error) => { failures.push(error) })
|
||||
const loopFiber = await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('deferred') }],
|
||||
})
|
||||
await ctx.plugin(DeferredSessionPersistence)
|
||||
await loadStarted.promise
|
||||
|
||||
await loopFiber.dispose()
|
||||
await Promise.resolve()
|
||||
expect(failures).toEqual([])
|
||||
expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('normalizes a non-Error declarative resume rejection without duplicating an Error prefix', async () => {
|
||||
class RejectingSessionPersistence extends SessionPersistence {
|
||||
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
|
||||
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
|
||||
load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
// Third-party backends can reject arbitrary values; this exercises normalization.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
return Promise.reject('plain failure')
|
||||
}
|
||||
list(): Promise<SessionHeader[]> { return Promise.resolve([]) }
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const failures: Error[] = []
|
||||
ctx.on('agent/start-failed', (_id, error) => { failures.push(error) })
|
||||
const loopFiber = await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('rejected') }],
|
||||
})
|
||||
await ctx.plugin(RejectingSessionPersistence)
|
||||
|
||||
await vi.waitFor(() => { expect(failures).toHaveLength(1) })
|
||||
expect(failures[0]?.message).toBe('plain failure')
|
||||
expect(failures[0]?.cause).toBe('plain failure')
|
||||
await loopFiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('config-driven create uses a fresh ${id}-session-<uuid> per run (restart-safe)', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-session-'))
|
||||
dirs.push(root)
|
||||
@@ -137,7 +204,9 @@ describe('config-driven session id', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
|
||||
const failures: Array<{ id: string; error: Error }> = []
|
||||
ctx.on('agent/start-failed', (id, error) => { failures.push({ id, error }) })
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
|
||||
const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
@@ -148,6 +217,13 @@ describe('config-driven session id', () => {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed'))
|
||||
expect(failures).toHaveLength(1)
|
||||
expect(failures[0]?.id).toBe('main')
|
||||
expect(failures[0]?.error.message).toBe('session "does-not-exist" not found')
|
||||
expect(failures[0]?.error.cause).toBeInstanceOf(Error)
|
||||
expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(failures[0]?.error)
|
||||
await loopFiber.dispose()
|
||||
expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined()
|
||||
warn.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user