mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge origin/master into xtr/session-format-migration
This commit is contained in:
@@ -10,7 +10,7 @@ import type { Context, Events } from '@deepseek-ai/cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Agent } from './runtime-types.ts'
|
||||
import type { Agent } from './types.ts'
|
||||
|
||||
/** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */
|
||||
type Params<F> = F extends (...args: infer P) => unknown ? P : never
|
||||
|
||||
@@ -12,8 +12,8 @@ import { isPromise } from 'node:util/types'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { Agent, AgentOptions } from './runtime-types.ts'
|
||||
import type { Agent } from './types.ts'
|
||||
import type { AgentOptions } from './runtime-types.ts'
|
||||
|
||||
export * from './runtime-types.ts'
|
||||
export * from './types.ts'
|
||||
@@ -23,16 +23,6 @@ export * from './model-selection.ts'
|
||||
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
|
||||
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertLookupMap {
|
||||
agent: TypertLookup<Agent, SessionId>
|
||||
}
|
||||
|
||||
interface TypertContextMap {
|
||||
agent: TypertContext<SessionId>
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
agents: AgentRegistry
|
||||
@@ -276,6 +266,7 @@ export class AgentRegistry extends Service {
|
||||
typeCtx.typert.contexts.registerHost('agent', {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
||||
identity: candidate => candidate.agent?.id,
|
||||
resolve: sessionId => this.get(sessionId)?.ctx,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { AgentCancelCause, Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
export type { AgentCancelCause } from '@deepseek-ai/dsh-session'
|
||||
import type { Inbox } from './inbox.ts'
|
||||
import type { InboxTarget } from './types.ts'
|
||||
import type { Agent } from './types.ts'
|
||||
export type { Agent } from './types.ts'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
declare module '@deepseek-ai/dsh-system-prompt' {
|
||||
interface AssembleContext {
|
||||
@@ -60,39 +61,37 @@ export type RequestErrorAction = { kind: 'retry' } | undefined
|
||||
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
|
||||
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
||||
|
||||
/** Public live-agent handle. */
|
||||
export interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
readonly id: SessionId
|
||||
/** The provider route and model this agent's requests use. */
|
||||
readonly options: AgentOptions
|
||||
/** The live session this agent drives; its log is the durable source of truth. */
|
||||
readonly session: Session
|
||||
/** The agent-owned projection of durable pending work. */
|
||||
readonly inbox: Inbox
|
||||
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
||||
readonly status: AgentStatus
|
||||
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||
readonly ctx: Context
|
||||
declare module './types.ts' {
|
||||
interface Agent {
|
||||
/** The provider route and model this agent's requests use. */
|
||||
readonly options: AgentOptions
|
||||
/** The live session this agent drives; its log is the durable source of truth. */
|
||||
readonly session: Session
|
||||
/** The agent-owned projection of durable pending work. */
|
||||
readonly inbox: Inbox
|
||||
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
||||
readonly status: AgentStatus
|
||||
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||
readonly ctx: Context
|
||||
|
||||
/**
|
||||
/**
|
||||
* Clear queued and steering work — unless `keepInbox` — and abort the active
|
||||
* turn or between-turn task. The first cause wins for that activity. With no
|
||||
* active activity, cancellation is a no-op and does not arm later work.
|
||||
* @param cause - the stable caller intent carried by the active operation signal.
|
||||
* @param options - cancellation options; `keepInbox` preserves pending work.
|
||||
*/
|
||||
cancel(cause: AgentCancelCause, options?: CancelOptions): void
|
||||
cancel(cause: AgentCancelCause, options?: CancelOptions): void
|
||||
|
||||
/**
|
||||
/**
|
||||
* Resolve after the current whole-agent activity reaches quiescence. This
|
||||
* follows replacement work started before the observed driver retires,
|
||||
* but does not identify the settlement of any particular message.
|
||||
* @returns fulfillment after no active driver or maintenance task remains.
|
||||
*/
|
||||
whenIdle(): Promise<void>
|
||||
whenIdle(): Promise<void>
|
||||
|
||||
/**
|
||||
/**
|
||||
* Run one non-turn maintenance task from the true idle phase. The task starts
|
||||
* synchronously after claiming that phase; later waking input remains in the
|
||||
* inbox until the task settles, while public status stays `idle`.
|
||||
@@ -101,9 +100,9 @@ export interface Agent {
|
||||
* @throws synchronously when turn-driving or another maintenance task already owns the agent.
|
||||
* @returns the task promise.
|
||||
*/
|
||||
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
|
||||
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
|
||||
|
||||
/**
|
||||
/**
|
||||
* Route identified input to an inbox boundary and optionally wake the driver.
|
||||
* Waking input submitted after active cancellation is queued for the next
|
||||
* turn and runs when the aborted activity converges to idle; a `disposed`
|
||||
@@ -114,25 +113,25 @@ export interface Agent {
|
||||
* @param target - the preferred next-turn or next-step inbox boundary.
|
||||
* @param wakeup - whether delivery may wake the driver.
|
||||
*/
|
||||
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
|
||||
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
|
||||
|
||||
/**
|
||||
/**
|
||||
* Queue an ordinary follow-up turn and wake the driver. The item becomes the
|
||||
* sole ordinary message of its own turn.
|
||||
* @param message - identified prompt content and the source that supplied it.
|
||||
*/
|
||||
followup(message: UserMessage): void
|
||||
followup(message: UserMessage): void
|
||||
|
||||
/**
|
||||
/**
|
||||
* Submit steering for the nearest step. An idle driver starts a turn;
|
||||
* a running driver consumes it at its next step boundary.
|
||||
* A rejected step leaves steering parked in the inbox until the next
|
||||
* wake; cancellation or disposal may discard pending steering.
|
||||
* @param message - identified steering content and the source that supplied it.
|
||||
*/
|
||||
steer(message: UserMessage): void
|
||||
steer(message: UserMessage): void
|
||||
|
||||
/**
|
||||
/**
|
||||
* Queue model-facing context for the next pre-step without waking the
|
||||
* driver. A running driver claims it at the nearest later step boundary;
|
||||
* idle drivers leave it pending until follow-up or steering
|
||||
@@ -140,7 +139,8 @@ export interface Agent {
|
||||
* batch. Cancellation or disposal may discard pending context.
|
||||
* @param message - identified injected context and the source that supplied it.
|
||||
*/
|
||||
inject(message: UserMessage): void
|
||||
inject(message: UserMessage): void
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
|
||||
@@ -5,6 +5,25 @@
|
||||
*/
|
||||
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
|
||||
|
||||
/** Public live-agent handle; the runtime face augments its live capabilities. */
|
||||
export interface Agent {
|
||||
/** Session-backed Agent identity. */
|
||||
readonly id: SessionId
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertLookupMap {
|
||||
agent: TypertLookup<Agent, SessionId>
|
||||
}
|
||||
|
||||
interface TypertContextMap {
|
||||
/** Agent Context identity shared by Host and Client adapters. */
|
||||
agent: TypertContext<SessionId>
|
||||
}
|
||||
}
|
||||
|
||||
/** One of the two ordered pending-message lists owned by an agent. */
|
||||
export type InboxTarget = 'next-turn' | 'next-step'
|
||||
|
||||
@@ -149,6 +149,7 @@ describe('AgentRegistry', () => {
|
||||
await agentFiber
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const agent = stubAgent('remote-agent')
|
||||
Object.defineProperty(agent, 'ctx', { value: agent.ctx.extend({ agent }) })
|
||||
const disposeAgent = ctx.agents.register(agent)
|
||||
|
||||
const lookup = ctx.typert.lookups.get('agent')
|
||||
@@ -159,7 +160,10 @@ describe('AgentRegistry', () => {
|
||||
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
||||
})
|
||||
expect(lookup?.resolve(agent.id)).toBe(agent)
|
||||
expect(ctx.typert.contexts.getHost('agent')?.resolve(agent.id)).toBe(agent.ctx)
|
||||
const context = ctx.typert.contexts.getHost('agent')
|
||||
expect(context?.identity(agent.ctx)).toBe(agent.id)
|
||||
expect(context?.identity(ctx)).toBeUndefined()
|
||||
expect(context?.resolve(agent.id)).toBe(agent.ctx)
|
||||
|
||||
disposeAgent()
|
||||
expect(lookup?.resolve(agent.id)).toBeUndefined()
|
||||
|
||||
@@ -34,6 +34,7 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
|
||||
'tools/post-execute': args => (args[0] as Record<string, unknown>)['agent'],
|
||||
'tools/pre-execute': args => (args[0] as Record<string, unknown>)['agent'],
|
||||
'tools/result': args => (args[0] as Record<string, unknown>)['agent'],
|
||||
'user-questions/request': args => (args[0] as Record<string, unknown>)['agent'],
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('scoped-dispatch invariants', () => {
|
||||
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
|
||||
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
|
||||
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]],
|
||||
['user-questions/request', [{ agent, questions: [] }, () => Promise.resolve({ answers: [] })]],
|
||||
]
|
||||
|
||||
for (const [event, args] of rows) {
|
||||
|
||||
@@ -147,7 +147,6 @@ function validateEvent(
|
||||
case 'session/end-seed':
|
||||
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
|
||||
break
|
||||
case 'todo/write':
|
||||
case 'request/header':
|
||||
case 'request/context': {
|
||||
if (trace.openTurn === null) {
|
||||
|
||||
@@ -42,6 +42,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'request/header',
|
||||
'sandbox/mode',
|
||||
'schedule/change',
|
||||
'session-log-deepseek/delivery-accepted',
|
||||
'session/end-seed',
|
||||
'session/title',
|
||||
'session/title-llm-request',
|
||||
|
||||
@@ -176,23 +176,6 @@ export interface TurnEndReasonMap {
|
||||
/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
|
||||
export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
|
||||
|
||||
/**
|
||||
* One entry in an agent's todo list — the unit of the `todo/write`
|
||||
* {@link SessionEventMap} event's whole-list snapshot.
|
||||
*
|
||||
* Deliberately minimal: a human-readable `content` line and a three-state
|
||||
* `status`. No id, priority, or `activeForm` — the list is replaced wholesale
|
||||
* on every write (last-write-wins), so entries need no stable identity. The
|
||||
* three statuses describe the complete portable lifecycle needed by model and
|
||||
* UI consumers.
|
||||
*/
|
||||
export interface TodoItem {
|
||||
/** What this task is — a short imperative line shown in the UI. */
|
||||
content: string
|
||||
/** Lifecycle state. `in_progress` marks a task being worked now; parallel work may mark several. */
|
||||
status: 'pending' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Logged request state outside derived history: call config, system prompt, and
|
||||
* tools. The latest full `request/header` snapshot reconstructs it; canonical
|
||||
@@ -299,8 +282,6 @@ export interface SessionEventMap {
|
||||
error?: { name: string; code: string }
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
|
||||
'todo/write': { todos: TodoItem[] }
|
||||
/**
|
||||
* Full header for the next request, appended inside its step before dispatch.
|
||||
* It is log-only; the latest snapshot reconstructs the request header.
|
||||
|
||||
@@ -141,7 +141,6 @@ describe('session-log invariants', () => {
|
||||
const enclosed = (await setup()).ctx.sessions.create()
|
||||
enclosed.append('turn/start', { turn: 1 })
|
||||
enclosed.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => enclosed.append('todo/write', { todos: [] })).not.toThrow()
|
||||
expect(() => enclosed.append('request/header', {
|
||||
header: { config: { provider: 'mock', model: 'mock' } },
|
||||
reason: 'initial',
|
||||
|
||||
@@ -146,7 +146,9 @@ describe('Session.requestContext', () => {
|
||||
it('advances incrementally across appends and skips unrelated events', () => {
|
||||
const session = Session.create(SessionId('incremental-capacity'), seedWith(CAPACITY))
|
||||
expect(session.requestContext()).toEqual(CAPACITY)
|
||||
session.append('todo/write', { todos: [] })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(session.requestContext()).toEqual(CAPACITY)
|
||||
session.append('request/context', { ...CAPACITY, model: 'next', contextWindow: 64_000 })
|
||||
expect(session.requestContext()).toEqual({ provider: 'mock', model: 'next', contextWindow: 64_000 })
|
||||
@@ -158,7 +160,9 @@ describe('Session.requestContext', () => {
|
||||
const session = Session.create(SessionId('batched-capacity'), seedWith(CAPACITY))
|
||||
expect(session.requestContext()).toEqual(CAPACITY)
|
||||
session.append('request/context', { ...CAPACITY, contextWindow: 200_000 })
|
||||
session.append('todo/write', { todos: [] })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('request/context', { ...CAPACITY, contextWindow: 300_000 })
|
||||
expect(session.requestContext()?.contextWindow).toBe(300_000)
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import SessionStore, {
|
||||
SessionId,
|
||||
snapshotSessionEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface } from '@deepseek-ai/dsh-session'
|
||||
|
||||
describe('Session', () => {
|
||||
it('exposes one stable readonly surface view', () => {
|
||||
@@ -787,7 +787,7 @@ describe('Session', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const event = session.append('todo/write', data as never)
|
||||
const event = session.append('request/context', data as never)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(event.data).toEqual({ value: 'accepted' })
|
||||
@@ -914,14 +914,14 @@ describe('Session', () => {
|
||||
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
|
||||
|
||||
const appended = Session.create(SessionId('append-frozen'))
|
||||
const appendedEvent = appended.append('todo/write', {
|
||||
todos: [{ content: 'first', status: 'pending' }],
|
||||
})
|
||||
const appendedEvent = appended.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(Object.isFrozen(appendedEvent)).toBe(true)
|
||||
expect(Object.isFrozen(appendedEvent.data)).toBe(true)
|
||||
expect(Object.isFrozen(appendedEvent.data.todos)).toBe(true)
|
||||
expect(Object.isFrozen(appendedEvent.data.todos[0])).toBe(true)
|
||||
expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
|
||||
expect(Object.isFrozen(appendedEvent.data.content)).toBe(true)
|
||||
expect(Object.isFrozen(appendedEvent.data.content[0])).toBe(true)
|
||||
expect(() => { (appendedEvent.data.content[0] as { text: string }).text = 'mutated' }).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('iteratively freezes deeply nested restored event data', () => {
|
||||
@@ -1531,7 +1531,7 @@ describe('SessionStore', () => {
|
||||
const session = ctx.sessions.create(SessionId('reentrant-observer'))
|
||||
const heard: SessionEvent[] = []
|
||||
ctx.on('session/event', (observedSession) => {
|
||||
observedSession.append('todo/write', { todos: [] })
|
||||
observedSession.append('request/context', { provider: 'mock', model: 'mock' })
|
||||
})
|
||||
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
|
||||
|
||||
@@ -1663,68 +1663,3 @@ describe('SessionStore', () => {
|
||||
expect(heard).toEqual([session])
|
||||
})
|
||||
})
|
||||
|
||||
describe('todo/write event', () => {
|
||||
it('appends the whole-list snapshot and isolates the log from later mutation', () => {
|
||||
const session = Session.create(SessionId('t1'))
|
||||
const todos: TodoItem[] = [
|
||||
{ content: 'plan the work', status: 'in_progress' },
|
||||
{ content: 'write the code', status: 'pending' },
|
||||
]
|
||||
session.append('todo/write', { todos })
|
||||
|
||||
const event = session.events.findLast(e => e.type === 'todo/write')!
|
||||
expect(event.type).toBe('todo/write')
|
||||
expect(event.data.todos).toEqual(todos)
|
||||
|
||||
// The append snapshots its input: mutating the caller's array afterward must
|
||||
// not change what the log holds (the durable-source-of-truth contract).
|
||||
todos.push({ content: 'sneak in', status: 'pending' })
|
||||
todos[0]!.status = 'completed'
|
||||
expect(event.data.todos).toEqual([
|
||||
{ content: 'plan the work', status: 'in_progress' },
|
||||
{ content: 'write the code', status: 'pending' },
|
||||
])
|
||||
})
|
||||
|
||||
it('is last-write-wins: the current list is the most recent todo/write', () => {
|
||||
const session = Session.create(SessionId('t2'))
|
||||
session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
|
||||
session.append('todo/write', { todos: [
|
||||
{ content: 'first', status: 'completed' },
|
||||
{ content: 'second', status: 'in_progress' },
|
||||
] })
|
||||
|
||||
const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos
|
||||
expect(current).toEqual([
|
||||
{ content: 'first', status: 'completed' },
|
||||
{ content: 'second', status: 'in_progress' },
|
||||
])
|
||||
})
|
||||
|
||||
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
|
||||
const session = Session.create(SessionId('t3'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const before = session.deriveMessages().length
|
||||
session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
|
||||
// The todo event must not add a message to the derived history…
|
||||
expect(session.deriveMessages()).toHaveLength(before)
|
||||
// …and must not appear on the ordered surface.
|
||||
expect(session.surface.nodes).not.toContain(session.seq - 1)
|
||||
})
|
||||
|
||||
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
|
||||
const original = Session.create(SessionId('t4'))
|
||||
original.append('turn/start', { turn: 1 })
|
||||
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Seeding a non-surface event with no surfaceOp must not throw.
|
||||
const replayed = Session.create(SessionId('t4-replay'), [...original.events])
|
||||
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
|
||||
.toEqual([{ content: 'only', status: 'completed' }])
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
expect(replayed.firstLiveSeq).toBe(original.seq)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user