feat(agent): unify send(target × wakeup), coalesce context/message into user/message

Replace send/steer/inject with one Agent.send primitive over the
(target × wakeup) matrix; followup/steer/inject become fixed-preset
alias methods on the now-abstract Agent class. Coalesce context/message
into user/message (injected context is a non-user source). Replace
agent/queued with agent/inbox/enqueue/dequeue/discard, add cancel
keepInbox, and add a FIFO-conservation invariant.
This commit is contained in:
Turtle
2026-07-23 19:15:45 +08:00
parent 7c0c516f60
commit 44fd93fd06
117 changed files with 1249 additions and 728 deletions

View File

@@ -17,7 +17,7 @@ async function setup(): Promise<Context> {
function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent {
return {
type: 'context/message',
type: 'user/message',
seq: 0,
time,
data: {
@@ -56,7 +56,7 @@ function preparing(turn: number, step: number): Session {
}
function appendReading(session: Session, text: string): void {
session.append('context/message', {
session.append('user/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'time-context' },
}, { surfaceOp: 'append' })
@@ -162,7 +162,7 @@ describe('time-context invariants', () => {
it('ignores context messages owned by another package', async () => {
const ctx = await setup()
const other = event('unrelated') as SessionEvent<'context/message'>
const other = event('unrelated') as SessionEvent<'user/message'>
other.data.source = { kind: 'plugin', plugin: 'other' }
expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
other.data.source = { kind: 'user' }

View File

@@ -48,7 +48,7 @@ describe('time-context through a real headless cordis.yml', () => {
expect(stderr).not.toContain('UNHANDLED')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
const contexts = events.filter(event => event.type === 'context/message')
const contexts = events.filter(event => event.type === 'user/message')
const starts = events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(2)
expect(starts).toHaveLength(2)

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -43,9 +43,10 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
status: 'running',
ctx: new Context(),
send() {},
followup() {},
steer() {},
inject(content, options) {
session.append('context/message', {
session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
}, { surfaceOp: 'append' })
@@ -66,7 +67,7 @@ function openMessageTurn(session: Session, turn: number): void {
function contextTexts(session: Session): string[] {
const texts: string[] = []
for (const event of session.events) {
if (event.type === 'context/message'
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context') {
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
@@ -151,8 +152,8 @@ describe('durable step context', () => {
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
])
const event = session.events.at(-1)
expect(event?.type).toBe('context/message')
if (event?.type !== 'context/message') throw new Error('missing time context')
expect(event?.type).toBe('user/message')
if (event?.type !== 'user/message') throw new Error('missing time context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
expect(event.surfaceOp).toBe('append')
})
@@ -230,10 +231,10 @@ describe('durable step context', () => {
const original = new Session(SessionId('seed-source'))
openMessageTurn(original, 1)
await fire(ctx, sessionAgent(original), 1, 1)
const user = original.events.find(event => event.type === 'user/message')
const reading = original.events.find(event => event.type === 'context/message')
const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user')
const reading = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
if (user === undefined || reading === undefined) throw new Error('missing source surface events')
original.append('context/message', {
original.append('user/message', {
content: [{ type: 'text', text: 'compacted history' }],
source: { kind: 'plugin', plugin: 'compact-basic' },
}, {
@@ -292,7 +293,7 @@ describe('durable step context', () => {
openMessageTurn(session, 1)
let ordinarySawContext = false
ctx.on('agent/pre-step', (subject) => {
ordinarySawContext = subject.session.events.some(event => event.type === 'context/message')
ordinarySawContext = subject.session.events.some(event => event.type === 'user/message')
})
await fire(ctx, agent, 1, 1)
@@ -401,7 +402,8 @@ describe('real agent-loop request history', () => {
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
const contexts = agent.session.events.filter(event => event.type === 'context/message')
const contexts = agent.session.events.filter(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin')
const starts = agent.session.events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(adapter.requests.length)
expect(starts).toHaveLength(adapter.requests.length)