refactor(agent): unify sourced message delivery
This commit is contained in:
@@ -14,7 +14,6 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import type {
|
||||
AgentMessage,
|
||||
Agent,
|
||||
AliasSendOptions,
|
||||
CancelOptions,
|
||||
AgentInterruptReason,
|
||||
AgentOptions,
|
||||
@@ -27,9 +26,7 @@ import type {
|
||||
import {
|
||||
BlockAssembler, LlmError, assertNever, deepFreeze, errorChain, isHarnessError, llmFailureOf, markAgentLoopRequest,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionId, TurnEndReason, TurnTrigger, UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -89,10 +86,11 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
/** Accept and route one unified send item. */
|
||||
send(
|
||||
content: ContentBlock[],
|
||||
input: UserMessageData,
|
||||
options: SendOptions,
|
||||
): AgentMessageId {
|
||||
const { target, wakeup, source } = options
|
||||
const { content, source } = input
|
||||
const { target, wakeup } = options
|
||||
const id = AgentMessageId(randomUUID())
|
||||
if (target === 'next-step' && !wakeup) {
|
||||
if (this.turnOpen) {
|
||||
@@ -120,29 +118,26 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
/** Queue one ordinary prompt turn and wake the driver. */
|
||||
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
||||
return this.send(content, {
|
||||
followup(input: UserMessageData): AgentMessageId {
|
||||
return this.send(input, {
|
||||
target: 'next-turn',
|
||||
wakeup: true,
|
||||
source: options?.source ?? { kind: 'user' },
|
||||
})
|
||||
}
|
||||
|
||||
/** Steer the open turn, falling back to a waking prompt while idle. */
|
||||
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
||||
return this.send(content, {
|
||||
steer(input: UserMessageData): AgentMessageId {
|
||||
return this.send(input, {
|
||||
target: 'next-step',
|
||||
wakeup: true,
|
||||
source: options?.source ?? { kind: 'user' },
|
||||
})
|
||||
}
|
||||
|
||||
/** Append model-facing context without waking the driver. */
|
||||
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
||||
return this.send(content, {
|
||||
inject(input: UserMessageData): AgentMessageId {
|
||||
return this.send(input, {
|
||||
target: 'next-step',
|
||||
wakeup: false,
|
||||
source: options?.source ?? { kind: 'plugin', plugin: '' },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { AdditionalContext } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** One tool call after argument parsing, ready to schedule. */
|
||||
@@ -58,7 +57,7 @@ export async function executeToolCalls(
|
||||
step: number,
|
||||
toolCalls: ToolCallBlock[],
|
||||
signal: AbortSignal,
|
||||
acceptContext: (context: AdditionalContext) => void,
|
||||
acceptContext: (context: UserMessageData) => void,
|
||||
): Promise<{ concluded: boolean }> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session } = agent
|
||||
@@ -120,7 +119,7 @@ async function runGroup(
|
||||
group: PlannedCall[],
|
||||
mode: ToolExecutionMode['kind'],
|
||||
signal: AbortSignal,
|
||||
acceptContext: (context: AdditionalContext) => void,
|
||||
acceptContext: (context: UserMessageData) => void,
|
||||
): Promise<GroupOutcome> {
|
||||
const { session } = ctx.agents.requireInitiator()
|
||||
const { maxParallelToolCalls } = ctx.agentLoop.config
|
||||
|
||||
@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string): void {
|
||||
agent.followup([{ type: 'text', text }])
|
||||
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
/** Adapter that holds both drivers at the same awaited continuation. */
|
||||
|
||||
@@ -21,7 +21,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string): void {
|
||||
agent.followup([{ type: 'text', text }])
|
||||
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
describe('Agent', () => {
|
||||
@@ -32,7 +32,7 @@ describe('Agent', () => {
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
|
||||
agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
agent.inject({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } })
|
||||
|
||||
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
|
||||
expect(agent.status).toBe('idle')
|
||||
@@ -41,11 +41,11 @@ describe('Agent', () => {
|
||||
expect(flushes).toBe(0)
|
||||
})
|
||||
|
||||
it('inject() defaults its source to an empty plugin, never user', async () => {
|
||||
it('inject() preserves an explicitly empty plugin source', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('ok')]))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.inject([{ type: 'text', text: 'no explicit source' }])
|
||||
agent.inject({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } })
|
||||
|
||||
const injected = agent.session.events.at(-1)
|
||||
expect(injected?.type === 'user/message' && injected.data.source)
|
||||
@@ -57,10 +57,7 @@ describe('Agent', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
expect(() => {
|
||||
agent.inject(
|
||||
[{ type: 'text', text: 'x', bad: 1n } as never],
|
||||
{ source: { kind: 'plugin', plugin: 'p' } },
|
||||
)
|
||||
agent.inject({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } })
|
||||
}).toThrow(/non-JSON-serializable/)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
})
|
||||
@@ -70,10 +67,7 @@ describe('Agent', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.steer(
|
||||
[{ type: 'text', text: 'steer idle' }],
|
||||
{ source: { kind: 'plugin', plugin: 'test' } },
|
||||
)
|
||||
agent.steer({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true)
|
||||
|
||||
@@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter) {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.followup([{ type: 'text', text }])
|
||||
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
/** Resolve on the agent's next idle transition (event-based, not status poll). */
|
||||
@@ -63,7 +63,7 @@ describe('Agent.cancel()', () => {
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => {
|
||||
if (subject !== agent) return
|
||||
seen.push(`first:${cause.kind}`)
|
||||
subject.followup([{ type: 'text', text: 'queued by cancel observer' }])
|
||||
subject.followup({ content: [{ type: 'text', text: 'queued by cancel observer' }], source: { kind: 'user' } })
|
||||
throw new Error('observer failed')
|
||||
})
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => {
|
||||
@@ -106,11 +106,7 @@ describe('Agent.cancel()', () => {
|
||||
ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) })
|
||||
|
||||
// Queue a turn WITHOUT waking the driver, so it sits in the inbox.
|
||||
agent.send([{ type: 'text', text: 'preserved' }], {
|
||||
target: 'next-turn',
|
||||
wakeup: false,
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
agent.send({ content: [{ type: 'text', text: 'preserved' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
|
||||
// keepInbox cancel: no active turn, work preserved, no discard event.
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
expect(discards).toEqual([])
|
||||
@@ -128,11 +124,7 @@ describe('Agent.cancel()', () => {
|
||||
|
||||
// A quiet item alone must NOT wake the driver: no turn runs and whenIdle
|
||||
// resolves (the agent is quiescent), leaving the item queued.
|
||||
agent.send([{ type: 'text', text: 'quiet' }], {
|
||||
target: 'next-turn',
|
||||
wakeup: false,
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
agent.send({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
|
||||
await agent.whenIdle()
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
@@ -148,11 +140,7 @@ describe('Agent.cancel()', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'quiet' }], {
|
||||
target: 'next-turn',
|
||||
wakeup: false,
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
agent.send({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
|
||||
const idle = agent.whenIdle()
|
||||
// Cancel reaches quiescence with no status transition and no waking send;
|
||||
// whenIdle must still resolve (previously it hung until the next send).
|
||||
@@ -587,7 +575,7 @@ describe('Agent.cancel()', () => {
|
||||
expect(agent.status).toBe('running')
|
||||
// Steer (joins the running turn's steering FIFO), then cancel: the steering
|
||||
// must be dropped, NOT re-enqueued as a new queued turn.
|
||||
agent.steer([{ type: 'text', text: 'steer text' }])
|
||||
agent.steer({ content: [{ type: 'text', text: 'steer text' }], source: { kind: 'user' } })
|
||||
agent.cancel({ kind: 'user' })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('config-driven session id', () => {
|
||||
first = ctx.agents.get(SessionId('config-exact-reload'))
|
||||
}
|
||||
expect(first).toBeDefined()
|
||||
first!.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
first!.followup({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, first!)
|
||||
await firstLoop.dispose()
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('config-driven session id', () => {
|
||||
}
|
||||
expect(second).toBeDefined()
|
||||
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
|
||||
second!.followup([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } })
|
||||
second!.followup({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, second!)
|
||||
await ctx.sessions.flush(second!.session)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload'))
|
||||
@@ -137,9 +137,7 @@ describe('config-driven session id', () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
})
|
||||
first.inject([{ type: 'text', text: 'persist before replacement' }], {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
first.inject({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
await ctx.sessions.flush(first.session)
|
||||
expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events))
|
||||
.toContain('persist before replacement')
|
||||
@@ -183,9 +181,7 @@ describe('config-driven session id', () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
})
|
||||
first.inject([{ type: 'text', text: 'persist before cancellation' }], {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
first.inject({ content: [{ type: 'text', text: 'persist before cancellation' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
await ctx.sessions.flush(first.session)
|
||||
expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events))
|
||||
.toContain('persist before cancellation')
|
||||
@@ -349,7 +345,7 @@ describe('config-driven session id', () => {
|
||||
expect(a1.id).toBe(a1.session.id)
|
||||
expect(a1.session.id).toMatch(idPattern)
|
||||
expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined()
|
||||
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
@@ -368,7 +364,7 @@ describe('config-driven session id', () => {
|
||||
expect(a2.id).toBe(a2.session.id)
|
||||
expect(a2.session.id).toMatch(idPattern)
|
||||
expect(a2.session.id).not.toBe(a1.session.id)
|
||||
a2.followup([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
|
||||
a2.followup({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx2, a2)
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
@@ -389,7 +385,7 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent
|
||||
a1.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
a1.followup({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.followup([{ type: 'text', text }])
|
||||
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
describe('assistant replay provenance', () => {
|
||||
@@ -85,7 +85,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
agent.inject([{ type: 'text', text: 'accepted before abort' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
agent.inject({ content: [{ type: 'text', text: 'accepted before abort' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
agent.cancel({ kind: 'user' })
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
@@ -185,7 +185,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
agent.inject([{ type: 'text', text: 'accepted before disposal' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
agent.inject({ content: [{ type: 'text', text: 'accepted before disposal' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
started.resolve(undefined)
|
||||
const signal = exec.signal
|
||||
if (!signal) throw new Error('tool execution signal is missing')
|
||||
@@ -254,7 +254,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
ctx.on('agent/step', (subject, turn) => {
|
||||
if (subject === agent && turn === 2) {
|
||||
agent.inject([{ type: 'text', text: 'new turn context' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
agent.inject({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
}
|
||||
})
|
||||
send(agent, 'start a text-only turn')
|
||||
@@ -282,7 +282,7 @@ describe('steering from late extension points is never stranded', () => {
|
||||
ctx.on('agent/stopping', () => {
|
||||
if (!steeredOnce) {
|
||||
steeredOnce = true
|
||||
agent.steer([{ type: 'text', text: 'one more thing' }])
|
||||
agent.steer({ content: [{ type: 'text', text: 'one more thing' }], source: { kind: 'user' } })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -307,7 +307,7 @@ describe('steering from late extension points is never stranded', () => {
|
||||
ctx.on('session/event', (subject, event) => {
|
||||
if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return
|
||||
steeredOnce = true
|
||||
agent.steer([{ type: 'text', text: 'goal reminder from step/end' }])
|
||||
agent.steer({ content: [{ type: 'text', text: 'goal reminder from step/end' }], source: { kind: 'user' } })
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
@@ -338,7 +338,7 @@ describe('steering from late extension points is never stranded', () => {
|
||||
if (event.type === 'turn/start') turns.push(event.data.turn)
|
||||
if (event.type === 'turn/end' && !steeredOnce) {
|
||||
steeredOnce = true
|
||||
agent.steer([{ type: 'text', text: 'too late for this turn' }])
|
||||
agent.steer({ content: [{ type: 'text', text: 'too late for this turn' }], source: { kind: 'user' } })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -493,7 +493,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'goal' } })
|
||||
agent.steer({ content: [{ type: 'text', text: 's' }], source: { kind: 'plugin', plugin: 'goal' } })
|
||||
return []
|
||||
},
|
||||
}))
|
||||
@@ -550,7 +550,7 @@ describe('turn numbering continues across seeded sessions', () => {
|
||||
|
||||
const turns: number[] = []
|
||||
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
|
||||
forked.followup([{ type: 'text', text: 'continue' }])
|
||||
forked.followup({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })
|
||||
await new Promise<void>((resolve) => {
|
||||
ctx2.on('agent/status', (subject, status) => {
|
||||
if (subject === forked && status === 'idle') resolve()
|
||||
|
||||
@@ -38,7 +38,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.followup([{ type: 'text', text }])
|
||||
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
describe('tool JSON parse', () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.followup([{ type: 'text', text }])
|
||||
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
function events(agent: Agent): SessionEvent[] {
|
||||
@@ -151,7 +151,7 @@ describe('agent/prompt-submit', () => {
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'do something' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } })
|
||||
await agent.whenIdle()
|
||||
|
||||
// the model was never called
|
||||
@@ -252,7 +252,7 @@ describe('agent/session-start', () => {
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
agent.inject({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
})
|
||||
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -396,10 +396,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
apply(ctx: Context) {
|
||||
// 1. SessionStart: seed a standing instruction.
|
||||
ctx.on('agent/session-start', (agent, source) => {
|
||||
agent.inject(
|
||||
[{ type: 'text', text: `policy active (started: ${source})` }],
|
||||
{ source: { kind: 'plugin', plugin: 'native-guard' } },
|
||||
)
|
||||
agent.inject({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } })
|
||||
})
|
||||
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise<PromptDecision> => {
|
||||
|
||||
@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.followup([{ type: 'text', text }])
|
||||
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
describe('agent loop', () => {
|
||||
@@ -271,7 +271,7 @@ describe('agent loop', () => {
|
||||
parameters: {},
|
||||
async execute() {
|
||||
// steer while the turn is running (during tool execution)
|
||||
agent.steer([{ type: 'text', text: 'change of plans' }])
|
||||
agent.steer({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } })
|
||||
return [{ type: 'text', text: 'tool done' }]
|
||||
},
|
||||
}))
|
||||
@@ -299,8 +299,8 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.steer([{ type: 'text', text: 'first idle steer' }])
|
||||
agent.steer([{ type: 'text', text: 'second idle steer' }])
|
||||
agent.steer({ content: [{ type: 'text', text: 'first idle steer' }], source: { kind: 'user' } })
|
||||
agent.steer({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } })
|
||||
await idle
|
||||
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
@@ -321,7 +321,7 @@ describe('agent loop', () => {
|
||||
ctx.on('agent/step', (subject) => {
|
||||
if (subject !== agent || !fail) return
|
||||
fail = false
|
||||
subject.steer([{ type: 'text', text: 'pending steering' }])
|
||||
subject.steer({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } })
|
||||
throw new Error('step failed')
|
||||
})
|
||||
|
||||
@@ -347,7 +347,7 @@ describe('agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
|
||||
agent.inject({ content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' } })
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0)
|
||||
@@ -369,9 +369,7 @@ describe('agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' })
|
||||
const text = '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>'
|
||||
agent.inject([{ type: 'text', text }], {
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
})
|
||||
agent.inject({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'workspace-context' } })
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -398,11 +396,9 @@ describe('agent loop', () => {
|
||||
async execute() {
|
||||
await Promise.resolve()
|
||||
const first = { type: 'text' as const, text: 'mid-turn notice' }
|
||||
agent.inject([first], {
|
||||
source: { kind: 'plugin', plugin: 'x' },
|
||||
})
|
||||
agent.inject({ content: [first], source: { kind: 'plugin', plugin: 'x' } })
|
||||
first.text = 'mutated after inject'
|
||||
agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } })
|
||||
agent.inject({ content: [{ type: 'text', text: 'second notice' }], source: { kind: 'plugin', plugin: 'x' } })
|
||||
visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
return [{ type: 'text', text: 'ok' }]
|
||||
},
|
||||
@@ -455,9 +451,7 @@ describe('agent loop', () => {
|
||||
parameters: {},
|
||||
async execute() {
|
||||
expect(() => {
|
||||
agent.inject([{ type: 'text', text: 'invalid' }], {
|
||||
source: { kind: 'plugin', plugin: 'test', bigint: 1n } as never,
|
||||
})
|
||||
agent.inject({ content: [{ type: 'text', text: 'invalid' }], source: { kind: 'plugin', plugin: 'test', bigint: 1n } as never })
|
||||
}).toThrow('agent context must be losslessly JSON-serializable')
|
||||
return [{ type: 'text', text: 'rejected invalid context' }]
|
||||
},
|
||||
@@ -482,9 +476,7 @@ describe('agent loop', () => {
|
||||
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
|
||||
ctx.on('agent/stopping', (subject) => {
|
||||
if (steps < 3) {
|
||||
subject.steer([{ type: 'text', text: 'continue' }], {
|
||||
source: { kind: 'plugin', plugin: 'loop-test' },
|
||||
})
|
||||
subject.steer({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'plugin', plugin: 'loop-test' } })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -692,9 +684,7 @@ describe('agent loop', () => {
|
||||
// (step 2 is a plain stop with no tool calls → stops).
|
||||
ctx.on('agent/stopping', (subject) => {
|
||||
if (steps < 2) {
|
||||
subject.steer([{ type: 'text', text: 'continue after truncation' }], {
|
||||
source: { kind: 'plugin', plugin: 'max-tokens-test' },
|
||||
})
|
||||
subject.steer({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'plugin', plugin: 'max-tokens-test' } })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -920,12 +910,9 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.followup([{ type: 'text', text: 'user message' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'user message' }], source: { kind: 'user' } })
|
||||
await Promise.resolve()
|
||||
agent.followup(
|
||||
[{ type: 'text', text: 'plugin message' }],
|
||||
{ source: { kind: 'plugin', plugin: 'test' } },
|
||||
)
|
||||
agent.followup({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
await idle
|
||||
|
||||
const triggers = agent.session.events
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('agent loop scheduling properties', () => {
|
||||
const { seen: trace } = recordStatus(ctx, agent)
|
||||
const idle = nextIdle(ctx, agent)
|
||||
// Send all in one synchronous tick: they queue before the loop wakes.
|
||||
for (const text of texts) agent.followup([{ type: 'text', text }])
|
||||
for (const text of texts) agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
await idle
|
||||
|
||||
// No message lost: every send appears as a user/message, in order.
|
||||
@@ -142,7 +142,7 @@ describe('agent loop scheduling properties', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
|
||||
for (const text of texts) {
|
||||
const idle = nextIdle(ctx, agent)
|
||||
agent.followup([{ type: 'text', text }])
|
||||
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
await idle
|
||||
}
|
||||
// Each send was drained at a separate turn start: N turns, 1..N.
|
||||
@@ -171,7 +171,7 @@ describe('agent loop scheduling properties', () => {
|
||||
for (const step of steps) {
|
||||
const idle = nextIdle(ctx, agent)
|
||||
lastIdle = idle
|
||||
agent.followup([{ type: 'text', text: step.text }])
|
||||
agent.followup({ content: [{ type: 'text', text: step.text }], source: { kind: 'user' } })
|
||||
if (step.settle) await idle
|
||||
}
|
||||
await lastIdle
|
||||
|
||||
@@ -73,10 +73,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (
|
||||
const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
// Turn 1: forces a tool call → at least two steps (two model requests).
|
||||
agent.followup([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
// Turn 2: a follow-up over the same (longer) prefix.
|
||||
agent.followup([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'Thanks. Repeat that value one more time.' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const usages = [...agent.session.events]
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('agent/request-error', () => {
|
||||
recoveries += 1
|
||||
})
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(recoveries).toBe(0)
|
||||
@@ -75,7 +75,7 @@ describe('agent/request-error', () => {
|
||||
subject.retry()
|
||||
})
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(seen.map(item => ({
|
||||
@@ -113,7 +113,7 @@ describe('agent/request-error', () => {
|
||||
subject.cancel({ kind: 'user' })
|
||||
})
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
@@ -136,7 +136,7 @@ describe('agent/request-error', () => {
|
||||
throw new Error('recovery failed')
|
||||
})
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
|
||||
@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.followup([{ type: 'text', text }])
|
||||
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
/** Assert `previous` is a strict value-prefix of `current`. */
|
||||
@@ -170,7 +170,7 @@ describe('request stability across the loop', () => {
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
|
||||
if (!injected) {
|
||||
injected = true
|
||||
agent.inject([{ type: 'text', text: '[late context]' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
agent.inject({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent
|
||||
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
|
||||
expect(sources1).toEqual(['startup'])
|
||||
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
@@ -475,9 +475,9 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
|
||||
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
a1.inject({ content: [{ type: 'text', text: 'background task 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
await a1.whenIdle()
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
@@ -503,7 +503,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
const adapter1 = new MockAdapter([textResponse('first answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent
|
||||
a1.followup([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
|
||||
a1.followup({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
const events1 = [...a1.session.events]
|
||||
const seqs1 = events1.map(e => e.seq)
|
||||
@@ -530,7 +530,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
|
||||
|
||||
// …and a new turn continues numbering (turn 2) with contiguous seqs.
|
||||
a2.followup([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } })
|
||||
a2.followup({ content: [{ type: 'text', text: 'second question' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx2, a2)
|
||||
const allSeqs = a2.session.events.map(e => e.seq)
|
||||
expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates
|
||||
|
||||
@@ -203,11 +203,11 @@ describe('agent scope lifecycle', () => {
|
||||
if (event.type === 'user/message') heard.push('a-sees:user-message')
|
||||
})
|
||||
|
||||
b.followup(text('for b'))
|
||||
b.followup({ content: text('for b'), source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, b)
|
||||
expect(heard).toEqual([]) // nothing of b's leaked into a's scope
|
||||
|
||||
a.followup(text('for a'))
|
||||
a.followup({ content: text('for a'), source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, a)
|
||||
expect(heard).toContain('a-sees:a:running')
|
||||
expect(heard).toContain('a-sees:user-message')
|
||||
@@ -934,7 +934,7 @@ describe('agent scope lifecycle', () => {
|
||||
if (event.type === 'turn/start') { off(); resolve() }
|
||||
})
|
||||
})
|
||||
agent.followup(text('work'))
|
||||
agent.followup({ content: text('work'), source: { kind: 'user' } })
|
||||
await turnOpen
|
||||
await owner.dispose()
|
||||
expect(order).toEqual([
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => gated.started.length === 3)
|
||||
expect(gated.started).toEqual(['1', '2', '3'])
|
||||
gated.release('1'); gated.release('2'); gated.release('3')
|
||||
@@ -133,7 +133,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3'])
|
||||
@@ -169,7 +169,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => replacement.started.length === 1)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
expect(replacement.started).toEqual(['1'])
|
||||
@@ -200,7 +200,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => initial.started.length === 2)
|
||||
initial.release('1')
|
||||
await until(() => events(agent).some(event =>
|
||||
@@ -226,7 +226,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => gated.started.length === 2)
|
||||
gated.release('2')
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
@@ -248,7 +248,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => gated.started.length === 2)
|
||||
gated.release('2'); gated.release('1')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -295,7 +295,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => gated.started.length === 2)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
expect(gated.started).toEqual(['1', '2'])
|
||||
@@ -324,7 +324,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => gated.started.length === 1)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
expect(gated.started).toEqual(['1'])
|
||||
@@ -350,7 +350,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => gated.started.length === 1)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
expect(gated.started).toEqual(['1'])
|
||||
@@ -377,7 +377,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
|
||||
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { post.push(String(exec.callId)); return next() })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => gated.started.length === 3)
|
||||
gated.release('3'); gated.release('2'); gated.release('1')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -398,7 +398,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
|
||||
({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => gated.started.length === 2)
|
||||
gated.release('2'); gated.release('1')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -436,7 +436,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => gated.started.length === 1)
|
||||
gated.release('1')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -466,7 +466,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
}
|
||||
})
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(gated.started).toEqual([])
|
||||
@@ -498,7 +498,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
return next()
|
||||
})
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(gated.started).toEqual([])
|
||||
@@ -528,7 +528,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => gated.started.length === 2)
|
||||
agent.cancel({ kind: 'user' })
|
||||
gated.release('1')
|
||||
@@ -575,7 +575,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await until(() => gated.started.length === 2)
|
||||
agent.cancel({ kind: 'user' })
|
||||
gated.release('1')
|
||||
|
||||
@@ -58,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf
|
||||
const ctx = await harness(adapter, toolOrder)
|
||||
for (const name of registrationOrder) registerNamed(ctx, name)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
return { ctx, agent, adapter }
|
||||
}
|
||||
@@ -102,7 +102,7 @@ describe('loop-level canonical tool order', () => {
|
||||
if (error instanceof Error) errors.push(error)
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])
|
||||
|
||||
@@ -56,10 +56,10 @@ Turn and step boundaries and the model token stream are durable `session/event`
|
||||
|
||||
The handle every plugin programs against:
|
||||
|
||||
- `agent.send(content, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle. `SendOptions` requires `target`, `wakeup`, and `source`; callers wanting the ordinary user-message preset use `followup(content)`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
|
||||
- `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
|
||||
- `agent.steer(content, options?)` — the `next-step`/wakeup preset: while a turn is open, stage steering for its next safe boundary without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Cancellation or disposal may discard pending steering.
|
||||
- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by `source`. While a turn is open, injection waits in the outbox for the next safe boundary. While idle, it appends immediately without opening a turn; persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.
|
||||
- `agent.send(input, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `input` is the existing `UserMessageData { content, source }`, while `SendOptions` requires only the routing policy `target` and `wakeup`. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
|
||||
- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
|
||||
- `agent.steer(input)` — the `next-step`/wakeup preset: while a turn is open, stage steering for its next safe boundary without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Cancellation or disposal may discard pending steering.
|
||||
- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. While a turn is open, injection waits in the outbox for the next safe boundary. While idle, it appends immediately without opening a turn; persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.
|
||||
- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
|
||||
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
|
||||
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
|
||||
@@ -78,7 +78,7 @@ The handle every plugin programs against:
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself.
|
||||
`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/step`, and other declared events let plugins block a prompt or add durable request material; this interface contributes no fixed prose itself.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -109,5 +109,5 @@ Prefix-stable while an agent's scoped registrations are unchanged. Setup or relo
|
||||
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
|
||||
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
|
||||
- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
|
||||
- **`AdditionalContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
|
||||
- **Each additional `UserMessageData` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
|
||||
- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`).
|
||||
|
||||
@@ -40,8 +40,7 @@ export type SendTarget = 'next-turn' | 'next-step'
|
||||
* (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and
|
||||
* {@link Agent.inject} (`next-step`/no-wakeup).
|
||||
*
|
||||
* The object is complete so routing and provenance are explicit; callers that
|
||||
* want the ordinary user-message preset use {@link Agent.followup}.
|
||||
* The object is complete so routing policy is explicit.
|
||||
*/
|
||||
export interface SendOptions {
|
||||
/** Queue the item joins. */
|
||||
@@ -54,14 +53,6 @@ export interface SendOptions {
|
||||
* (the injection preset).
|
||||
*/
|
||||
wakeup: boolean
|
||||
/** Producer provenance; direct human input uses `{ kind: 'user' }`. */
|
||||
source: MessageSource
|
||||
}
|
||||
|
||||
/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */
|
||||
export interface AliasSendOptions {
|
||||
/** Producer provenance; each alias supplies its documented default when omitted. */
|
||||
source?: MessageSource
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,8 +73,8 @@ export function AgentMessageId(id: string): AgentMessageId {
|
||||
/**
|
||||
* One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live
|
||||
* events. `id` is the value `send` returned to the caller, stable across this
|
||||
* message's enqueue, dequeue, and discard events. Source defaults are already
|
||||
* applied, so these are the exact values the item was accepted with.
|
||||
* message's enqueue, dequeue, and discard events. Its content and source are
|
||||
* the exact input values accepted by the agent.
|
||||
*/
|
||||
export interface AgentMessage extends UserMessageData {
|
||||
/** The id `send` returned for this message. */
|
||||
@@ -108,9 +99,6 @@ export interface CancelOptions {
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running'
|
||||
|
||||
/** Additional model-facing context produced beside a prompt or tool result. */
|
||||
export type AdditionalContext = UserMessageData
|
||||
|
||||
/**
|
||||
* Prompt interception result. `allow.content` replaces the prompt, while
|
||||
* `additionalContexts` appends model-facing context before the turn starts.
|
||||
@@ -118,7 +106,7 @@ export type AdditionalContext = UserMessageData
|
||||
* `next()` preserves both fields unless it intentionally replaces them.
|
||||
*/
|
||||
export type PromptDecision =
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: AdditionalContext[] }
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessageData[] }
|
||||
| { kind: 'block'; reason: string }
|
||||
|
||||
/** Model-request failure with an optional machine-routable provider code. */
|
||||
@@ -171,11 +159,11 @@ export interface Agent {
|
||||
* without running the model: an open turn stages it for the next safe log
|
||||
* position, while an idle injection appends it immediately without opening
|
||||
* a turn.
|
||||
* @param content - the model-facing content blocks to deliver.
|
||||
* @param options - target queue, wakeup decision, and source.
|
||||
* @param input - model-facing content and its producer provenance.
|
||||
* @param options - target queue and wakeup decision.
|
||||
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
|
||||
*/
|
||||
send(content: ContentBlock[], options: SendOptions): AgentMessageId
|
||||
send(input: UserMessageData, options: SendOptions): AgentMessageId
|
||||
|
||||
/**
|
||||
* Clear queued and steering work — unless `keepInbox` — and abort the active
|
||||
@@ -195,11 +183,10 @@ export interface Agent {
|
||||
* Queue an ordinary follow-up turn and wake the driver — the
|
||||
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole
|
||||
* ordinary message of its own turn.
|
||||
* @param content - the prompt content blocks.
|
||||
* @param options - message source.
|
||||
* @param input - prompt content and its producer provenance.
|
||||
* @returns the accepted message's {@link AgentMessageId}.
|
||||
*/
|
||||
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
|
||||
followup(input: UserMessageData): AgentMessageId
|
||||
|
||||
/**
|
||||
* Submit steering into the running turn — the `next-step`/wakeup preset of
|
||||
@@ -208,23 +195,20 @@ export interface Agent {
|
||||
* remainder stays staged without waking the agent; retry or a later prompt
|
||||
* takes it. Idle steering falls back to a woken follow-up turn, while
|
||||
* cancellation or disposal may discard pending steering.
|
||||
* @param content - the steering content blocks.
|
||||
* @param options - message source.
|
||||
* @param input - steering content and its producer provenance.
|
||||
* @returns the accepted message's {@link AgentMessageId}.
|
||||
*/
|
||||
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
|
||||
steer(input: UserMessageData): AgentMessageId
|
||||
|
||||
/**
|
||||
* Append model-facing context without running the model — the
|
||||
* `next-step`/no-wakeup preset of {@link send}. An open-turn injection stages
|
||||
* at the next safe log position; an idle injection appends immediately
|
||||
* without opening a turn. An omitted source defaults to
|
||||
* `{ kind: 'plugin', plugin: '' }`.
|
||||
* @param content - the injected context content blocks.
|
||||
* @param options - context source.
|
||||
* without opening a turn.
|
||||
* @param input - injected context and its producer provenance.
|
||||
* @returns the accepted message's {@link AgentMessageId}.
|
||||
*/
|
||||
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
|
||||
inject(input: UserMessageData): AgentMessageId
|
||||
|
||||
/**
|
||||
* Re-open a turn on the current session log without a new prompt — the
|
||||
@@ -325,8 +309,9 @@ declare module 'cordis' {
|
||||
// ---- the machine's extension seams ----
|
||||
/**
|
||||
* Allow, rewrite, or block one claimed prompt before it becomes a user
|
||||
* message. Call `next()` for the unchanged default. The signal controls only this turn;
|
||||
* listeners may cooperate with it but must not retain it for another turn.
|
||||
* message or opens a turn. Call `next()` for the unchanged default. The
|
||||
* signal controls only this admission attempt; listeners may cooperate with
|
||||
* it but must not retain it for a later attempt or turn.
|
||||
* @param agent - the agent whose turn claimed the message.
|
||||
* @param content - the claimed message's blocks, as queued.
|
||||
* @param source - the message's resolved source.
|
||||
|
||||
@@ -62,7 +62,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
|
||||
A `user/message` renders its `content` verbatim as a user-role message whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ The live registry pipeline has three transformable waterfalls, then the definiti
|
||||
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
|
||||
- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
|
||||
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately.
|
||||
- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `AdditionalContext` for the loop's post-result FIFO.
|
||||
- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `UserMessageData` for the loop's post-result FIFO.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
|
||||
- `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
|
||||
- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
|
||||
|
||||
@@ -10,9 +10,9 @@ import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } fr
|
||||
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { AdditionalContext, Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue, UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
|
||||
@@ -306,7 +306,7 @@ export interface ToolRunContext extends ToolExecution {
|
||||
* the agent loop. Contexts retain their individual source and metadata and
|
||||
* are emitted in call order.
|
||||
*/
|
||||
deferContext(context: AdditionalContext): void
|
||||
deferContext(context: UserMessageData): void
|
||||
/** Mark a successful final result as terminal for the current agent turn. */
|
||||
concludeTurn(): void
|
||||
}
|
||||
@@ -440,7 +440,7 @@ export interface ToolExecutionSuccess {
|
||||
readonly content: ContentBlock[]
|
||||
readonly error?: never
|
||||
readonly meta?: JsonValue
|
||||
readonly additionalContexts?: AdditionalContext[]
|
||||
readonly additionalContexts?: UserMessageData[]
|
||||
/** The agent loop stops after committing this successful result batch. */
|
||||
readonly concludesTurn?: true
|
||||
}
|
||||
@@ -452,7 +452,7 @@ export interface ToolExecutionFailure {
|
||||
readonly value?: never
|
||||
readonly content: ContentBlock[]
|
||||
readonly meta?: JsonValue
|
||||
readonly additionalContexts?: AdditionalContext[]
|
||||
readonly additionalContexts?: UserMessageData[]
|
||||
readonly concludesTurn?: never
|
||||
}
|
||||
|
||||
@@ -475,9 +475,9 @@ export type PreToolDecision =
|
||||
* next request, or block by turning corrective feedback into an error result.
|
||||
*/
|
||||
export type PostToolDecision =
|
||||
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: AdditionalContext[] }
|
||||
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: AdditionalContext[] }
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: AdditionalContext[] }
|
||||
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessageData[] }
|
||||
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessageData[] }
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessageData[] }
|
||||
|
||||
/**
|
||||
* Best-effort human-readable message from an arbitrary thrown value: Error
|
||||
@@ -652,7 +652,7 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
|
||||
private deferredContexts = new WeakMap<ToolRunContext, AdditionalContext[]>()
|
||||
private deferredContexts = new WeakMap<ToolRunContext, UserMessageData[]>()
|
||||
/** Successful executions whose tool body declared the current turn complete. */
|
||||
private concludingExecutions = new WeakSet<ToolExecution>()
|
||||
/** Enclosing transport tokens marked terminal by a successful nested call. */
|
||||
@@ -969,7 +969,7 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } {
|
||||
const deferredContexts: AdditionalContext[] = []
|
||||
const deferredContexts: UserMessageData[] = []
|
||||
const token = createExecutionToken()
|
||||
const callId = exec.callId
|
||||
const name = exec.name
|
||||
@@ -987,7 +987,7 @@ export class ToolRegistry extends Service {
|
||||
signal,
|
||||
...agent !== undefined ? { agent } : {},
|
||||
...parent !== undefined ? { parent } : {},
|
||||
deferContext(context: AdditionalContext): void {
|
||||
deferContext(context: UserMessageData): void {
|
||||
deferredContexts.push(context)
|
||||
},
|
||||
concludeTurn(): void {
|
||||
|
||||
Reference in New Issue
Block a user