Merge origin/master into codex/skill-system
This commit is contained in:
@@ -12,10 +12,10 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session).
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session).
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
|
||||
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge) hold a handle and own per-agent teardown.
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.
|
||||
|
||||
### Injected services
|
||||
|
||||
@@ -77,6 +77,6 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p
|
||||
- Hooks: `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`
|
||||
- Compaction: `agent/request`
|
||||
- Sandbox, permission, plan mode: `tools/execute`
|
||||
- Sub-agents: TODO seam on `AgentLoop.create()`
|
||||
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred.
|
||||
- Persistence: `session/event` + `session/flush`
|
||||
- UI: `agent/stream-chunk` + `agent/*` events
|
||||
|
||||
@@ -126,7 +126,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// A turn is open in the LOG (decided from the log, not agent status —
|
||||
// status can be `running` with no turn open): the context/message is
|
||||
// turn-enclosed by that turn, so append it directly.
|
||||
this.session.append('context/message', { content, source })
|
||||
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
|
||||
return
|
||||
}
|
||||
// No turn open: wrap the injection in a one-shot turn so every event stays
|
||||
@@ -143,7 +143,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// can't happen for our fixed trigger — no turn was opened and none is owed.)
|
||||
try {
|
||||
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
this.session.append('context/message', { content, source })
|
||||
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
|
||||
} finally {
|
||||
// Close the turn if turn/start made it into the log. Contain a throwing
|
||||
// turn/end listener: Session.append pushes before notifying, so a throw
|
||||
|
||||
@@ -375,7 +375,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
// every event in the log is turn-enclosed. turn/end is now owed, so a throw
|
||||
// while appending these is caught below and the turn is still closed.
|
||||
for (const message of queued) {
|
||||
session.append('user/message', { content: message.content, source: message.source })
|
||||
session.append('user/message', { content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
}
|
||||
ctx.emit('agent/turn-start', agent, turn)
|
||||
|
||||
@@ -543,7 +543,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boolean {
|
||||
const messages = agent.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source })
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
ctx.emit('agent/steering', agent, turn, message.content, message.source)
|
||||
}
|
||||
return messages.length > 0
|
||||
@@ -580,10 +580,12 @@ async function runStep(
|
||||
|
||||
// --- Model call (streaming-first; raw chunks are the replay record) ---
|
||||
const assembler = new BlockAssembler()
|
||||
const chunkSeqs: number[] = []
|
||||
for await (const chunk of ctx.llm.stream(request)) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
session.append('assistant/chunk', { turn, step, chunk })
|
||||
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
|
||||
chunkSeqs.push(chunkEvent.seq)
|
||||
ctx.emit('agent/stream-chunk', agent, turn, step, chunk)
|
||||
assembler.push(chunk)
|
||||
}
|
||||
@@ -606,7 +608,13 @@ async function runStep(
|
||||
// deriveMessages(), so hosting usage on it never injects a spurious assistant
|
||||
// turn into derived history.
|
||||
if (message.content.length > 0 || assembler.usage) {
|
||||
session.append('assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) })
|
||||
// A max-tokens finish is itself a streamed `finish` chunk, so chunkSeqs is
|
||||
// never empty here — pass the provenance unconditionally.
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
|
||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||
)
|
||||
}
|
||||
return { hadToolCalls: false, finish: assembler.finish }
|
||||
}
|
||||
@@ -622,8 +630,15 @@ async function runStep(
|
||||
// streamed nothing) records no assistant/message — an empty-content message
|
||||
// exists only to host usage, and deriveMessages() skips it either way, so
|
||||
// appending one with no usage would be a pure trace-only row.
|
||||
//
|
||||
// sourceEventSeqs records the assistant/chunk provenance, but is omitted when
|
||||
// no chunks streamed (the surface invariant rejects an empty sourceEventSeqs).
|
||||
if (message.content.length > 0 || assembler.usage) {
|
||||
session.append('assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) })
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
|
||||
{ surfaceOp: 'append', ...(chunkSeqs.length > 0 ? { sourceEventSeqs: chunkSeqs } : {}) },
|
||||
)
|
||||
}
|
||||
|
||||
// --- Tool execution (sequential; parallel execution is a TODO) ---
|
||||
@@ -633,7 +648,7 @@ async function runStep(
|
||||
for (const call of toolCalls) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
|
||||
const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
|
||||
let parsedArguments: unknown
|
||||
try {
|
||||
parsedArguments = call.arguments ? JSON.parse(call.arguments) : {}
|
||||
@@ -659,7 +674,7 @@ async function runStep(
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
})
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
|
||||
// signal CAN flip during the await above (abort() inside a tool);
|
||||
// the analyzer can't see through the await boundary.
|
||||
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
|
||||
|
||||
@@ -1018,3 +1018,32 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('surface: assistant/message omits sourceEventSeqs when no chunks streamed', () => {
|
||||
it('a step-result listener injecting content over an empty stream appends with surfaceOp but no sourceEventSeqs', async () => {
|
||||
// An empty stream yields zero assistant/chunk events (finish defaults to
|
||||
// `stop`), so chunkSeqs is empty. A step-result listener injects content, so
|
||||
// the content-or-usage guard fires and an assistant/message is appended. Its
|
||||
// sourceEventSeqs MUST be omitted (not `[]`) — the surface invariant rejects
|
||||
// an empty sourceEventSeqs, and the dev invariants plugin would throw on it.
|
||||
const adapter = new MockAdapter([[]])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(Invariants)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({
|
||||
role: 'assistant' as const,
|
||||
content: [{ type: 'text' as const, text: 'injected' }],
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const recorded = agent.session.events.find(e => e.type === 'assistant/message')!
|
||||
expect(recorded.type).toBe('assistant/message')
|
||||
expect(recorded.surfaceOp).toBe('append')
|
||||
expect(recorded.sourceEventSeqs).toBeUndefined()
|
||||
// The injected content reaches derived history.
|
||||
expect(JSON.stringify(agent.session.deriveMessages())).toContain('injected')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user