Merge remote-tracking branch 'origin/master' into feat/web-presenter
# Conflicts: # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
This commit is contained in:
@@ -134,6 +134,15 @@ interface PreparedAgent {
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
agentLoop: AgentLoop
|
||||
/**
|
||||
* Launcher-owned exact session identities for configured agents, keyed by
|
||||
* the agent's config `id` and set with `ctx.provide()` before any Loader
|
||||
* entry mounts (see {@link CONFIGURED_AGENT_IDENTITIES_KEY}). A launcher
|
||||
* owns identity because only it knows whether the session already exists,
|
||||
* while the `cordis.yml` row keeps the model route as ordinary patchable
|
||||
* config. An entry with no matching key keeps its configured identity.
|
||||
*/
|
||||
configuredAgentIdentities?: ConfiguredAgentIdentities
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
@@ -151,6 +160,53 @@ declare module 'cordis' {
|
||||
|
||||
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
|
||||
|
||||
/**
|
||||
* One launcher-selected session identity for a configured agent. `resume`
|
||||
* distinguishes rehydrating existing persisted history from creating the
|
||||
* session fresh under that exact id, which the two config keys express as
|
||||
* `resumeSessionId` and `sessionId`.
|
||||
*/
|
||||
export interface LauncherAgentIdentity {
|
||||
/** Exact session id to create fresh or resume. */
|
||||
id: SessionId
|
||||
/** Resume existing persisted history instead of creating the session fresh. */
|
||||
resume: boolean
|
||||
}
|
||||
|
||||
/** Launcher-selected identities keyed by the configured agent's `id`. */
|
||||
export interface ConfiguredAgentIdentities extends Readonly<Record<string, LauncherAgentIdentity>> {}
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix
|
||||
* configured agents' session identities without a config key, so an overlay
|
||||
* repointing the row's model route cannot drop them.
|
||||
*/
|
||||
export const CONFIGURED_AGENT_IDENTITIES_KEY = 'configuredAgentIdentities'
|
||||
|
||||
/**
|
||||
* Apply launcher-owned identities over the configured agents, replacing both
|
||||
* identity keys for every entry the launcher named so a config-supplied
|
||||
* identity can never survive alongside a launcher-supplied one.
|
||||
* @param agents - the configured agent entries.
|
||||
* @param identities - launcher identities keyed by configured agent `id`, or `undefined`.
|
||||
* @returns the entries with launcher-owned identities applied.
|
||||
*/
|
||||
function applyLauncherIdentities(
|
||||
agents: Config['agents'],
|
||||
identities: ConfiguredAgentIdentities | undefined,
|
||||
): Config['agents'] {
|
||||
if (identities === undefined) return agents
|
||||
return agents.map((agent) => {
|
||||
const identity = identities[agent.id]
|
||||
if (identity === undefined) return agent
|
||||
const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent
|
||||
return identity.resume
|
||||
? { ...rest, resumeSessionId: identity.id }
|
||||
: { ...rest, sessionId: identity.id }
|
||||
})
|
||||
}
|
||||
|
||||
/** Agent-loop plugin configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
@@ -220,6 +276,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
super(ctx, 'agentLoop')
|
||||
this.config = {
|
||||
...config,
|
||||
agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
|
||||
maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
|
||||
}
|
||||
validateConfiguredAgents(this.config.agents)
|
||||
|
||||
@@ -11,7 +11,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop, { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -36,6 +36,26 @@ async function makeCoreContext(): Promise<Context> {
|
||||
}
|
||||
|
||||
describe('config-driven session id', () => {
|
||||
it('applies launcher identities by configured id without changing unmatched entries', async () => {
|
||||
const ctx = await makeCoreContext()
|
||||
ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, {
|
||||
fresh: { id: SessionId('launcher-fresh'), resume: false },
|
||||
resumed: { id: SessionId('launcher-resumed'), resume: true },
|
||||
})
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [
|
||||
{ id: 'fresh', sessionId: SessionId('config-fresh'), model: 'mock' },
|
||||
{ id: 'resumed', sessionId: SessionId('config-resumed'), model: 'mock' },
|
||||
{ id: 'unchanged', sessionId: SessionId('config-unchanged'), model: 'mock' },
|
||||
],
|
||||
})
|
||||
expect(ctx.agents.get(SessionId('launcher-fresh'))?.session.id).toBe('launcher-fresh')
|
||||
expect(ctx.agents.get(SessionId('launcher-resumed'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('config-resumed'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('config-unchanged'))?.session.id).toBe('config-unchanged')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an empty exact id before publishing an agent', async () => {
|
||||
const ctx = await makeCoreContext()
|
||||
await expect(ctx.plugin(AgentLoop, {
|
||||
|
||||
@@ -1221,8 +1221,9 @@ describe('agent loop', () => {
|
||||
|
||||
const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] })
|
||||
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
|
||||
// event-by-event identity of types
|
||||
expect(replayed.events.map(e => e.type)).toEqual(
|
||||
// event-by-event identity of types over the inherited prefix
|
||||
expect(replayed.events.slice(0, agent.session.seq).map(e => e.type)).toEqual(
|
||||
agent.session.events.map(e => e.type))
|
||||
expect(replayed.events.at(-1)?.type).toBe('session/end-seed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -283,7 +283,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async (agentCtx) => {
|
||||
expect(agentCtx.agent?.id).toBe(sessionId)
|
||||
expect(agentCtx.agent?.session.events).toHaveLength(2)
|
||||
// The two persisted events plus the end-seed marker.
|
||||
expect(agentCtx.agent?.session.events).toHaveLength(3)
|
||||
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
||||
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
||||
order.push('setup:start')
|
||||
@@ -585,7 +586,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
|
||||
// The resumed session carries the prior history…
|
||||
expect(a2.session.id).toBe('sess-resume')
|
||||
expect(a2.session.events.length).toBe(events1.length)
|
||||
// …followed by one end-seed event marking the constructor seed.
|
||||
expect(a2.session.events.length).toBe(events1.length + 1)
|
||||
expect(a2.session.firstLiveSeq).toBe(events1.length)
|
||||
expect(a2.session.events.at(-1)?.type).toBe('session/end-seed')
|
||||
const replay = new Session(SessionId('replay'), events1)
|
||||
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export * from './types.ts'
|
||||
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
export { isJsonValue, snapshotJsonValue } from './json.ts'
|
||||
export type { JsonValue } from './json.ts'
|
||||
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
|
||||
export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
|
||||
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
|
||||
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
|
||||
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
|
||||
@@ -382,14 +382,25 @@ export class Session {
|
||||
|
||||
/**
|
||||
* The first seq appended IN THIS PROCESS: the length of the constructor
|
||||
* seed (0 without one). Events below it entered through construction —
|
||||
* replay, fork, or resume — and were never published on the `session/event`
|
||||
* firehose (constructor seeds do not emit), so consumers that replay the
|
||||
* log as a publication substitute (telemetry adoption) start here. Distinct
|
||||
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
|
||||
* session's constructor seed is its full stored log, while its header keeps
|
||||
* the original fork value — this field is the in-process construction fact
|
||||
* and is deliberately not persisted.
|
||||
* seed (0 without one). Events with smaller seq values entered through
|
||||
* construction — replay, fork, or resume — and were never published on the
|
||||
* `session/event` firehose (constructor seeds do not emit), so consumers
|
||||
* that replay the log as a publication substitute (telemetry adoption)
|
||||
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
|
||||
* boundary: a resumed session's constructor seed is its full stored log,
|
||||
* while its header keeps the original fork value — this field is the
|
||||
* in-process construction fact.
|
||||
*
|
||||
* Not persisted itself: a seeded session projects it into the log as the
|
||||
* `session/end-seed` event, which is what a consumer reading STORED history
|
||||
* reads. Locate the LAST such event, not necessarily one at this seq — a
|
||||
* seed already ending in one is not re-marked, so reopening an untouched
|
||||
* session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
|
||||
* this field in-process: it is exact before the marker reaches storage.
|
||||
*
|
||||
* When this lifecycle appends the marker, it occupies this seq before the
|
||||
* store attaches and therefore does not publish either. Otherwise this seq
|
||||
* holds an ordinary published write.
|
||||
*/
|
||||
readonly firstLiveSeq: number
|
||||
|
||||
@@ -427,6 +438,13 @@ export class Session {
|
||||
}
|
||||
this.firstLiveSeq = this.log.length
|
||||
this.header = snapshotSessionHeader(id, header)
|
||||
// Appended here so the marker is already in `events` when a backend
|
||||
// captures the creation seed: no load-time write. Re-marking is skipped
|
||||
// because a cold session is resumed on first touch, so repeatedly opening
|
||||
// one must not grow its log per open.
|
||||
if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/end-seed') {
|
||||
this.append('session/end-seed', {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Cached immutable public snapshot of the private append-only log. */
|
||||
|
||||
@@ -144,6 +144,9 @@ function validateEvent(
|
||||
}
|
||||
case 'user/message':
|
||||
break
|
||||
case 'session/end-seed':
|
||||
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
|
||||
break
|
||||
case 'steering/message':
|
||||
case 'todo/write':
|
||||
case 'request/header': {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Crash-recovery repair for an interrupted session log. It preserves a fully
|
||||
* written final turn and supplies the missing tool, step, and turn boundaries
|
||||
* needed to resume with a provider-valid transcript.
|
||||
* needed to resume with a provider-valid transcript, plus the activity-time
|
||||
* read that must skip the end-seed boundary — which this module does
|
||||
* not write (`Session`'s constructor does) but whose synthetic closers can
|
||||
* inherit that boundary's timestamp, the one real coupling between the two.
|
||||
* @module @deepseek-ai/dsh-session/repair
|
||||
*/
|
||||
|
||||
@@ -9,6 +12,22 @@ import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from './types.ts'
|
||||
|
||||
/**
|
||||
* The `time` of the log's last event representing actual work, skipping the
|
||||
* `session/end-seed` boundary — picking a session up is not activity, so
|
||||
* activity ordering must exclude it.
|
||||
*
|
||||
* Excluded by type, so a pickup time still leaks when a boundary is the last
|
||||
* event of an open turn: {@link interruptedTurnClosers} copies it onto the
|
||||
* synthetic `turn/end`, which this counts as work. Reachable only by seeding an
|
||||
* unbalanced log directly — `load()` balances first.
|
||||
* @param events - the log to scan, in seq order.
|
||||
* @returns the latest non-boundary event's `time`, or undefined when there is none.
|
||||
*/
|
||||
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
|
||||
return events.findLast(event => event.type !== 'session/end-seed')?.time
|
||||
}
|
||||
|
||||
/** Recovery code for an assistant tool request that never reached a recorded call start. */
|
||||
export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'
|
||||
|
||||
|
||||
@@ -250,6 +250,29 @@ export interface SessionEventMap {
|
||||
* It is log-only; the latest snapshot reconstructs the request header.
|
||||
*/
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
/**
|
||||
* Marks the end of a constructor seed. Events before it have smaller seq
|
||||
* values and came from the seed (resume, fork, or replay); this lifecycle
|
||||
* produced none of them. This log-only event is the durable projection of
|
||||
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
|
||||
* carry the meaning.
|
||||
*
|
||||
* Locate the LAST one in stored history. A seed already ending in one is not
|
||||
* re-marked, so reopening an untouched session does not grow its log per
|
||||
* pickup and the event need not be at the current `firstLiveSeq`.
|
||||
*
|
||||
* `Session`'s constructor is the only legitimate writer. The invariant
|
||||
* companion deliberately constrains nothing here, so a plugin appending one
|
||||
* would silently classify every live bracket before it as seed history.
|
||||
*
|
||||
* An owner of a standalone open/close bracket (`compact/start` …
|
||||
* `compact/end`) reads it because seed history and live work are otherwise
|
||||
* byte-identical: an unmatched opening marker before this event belongs to
|
||||
* an ended lifecycle, whatever ended it. NOT a liveness signal about other
|
||||
* writers — a concurrently live session holds its own boundary elsewhere,
|
||||
* so tolerating concurrent writers needs a signal beyond the log.
|
||||
*/
|
||||
'session/end-seed': Record<string, never>
|
||||
}
|
||||
|
||||
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
'test/log-only': { value: string }
|
||||
/** Stands in for a plugin's open/close bracket (`compact/start`). */
|
||||
'test/bracket-open': { id: string }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +52,14 @@ function lastSeq(session: Session): number {
|
||||
return event.seq
|
||||
}
|
||||
|
||||
/** A seeded child's constructor seed: its log minus the end-seed marker. */
|
||||
function inherited(session: Session): readonly SessionEvent[] {
|
||||
const events = session.events
|
||||
const last = events.at(-1)
|
||||
if (last?.type !== 'session/end-seed') throw new Error('seeded child is missing its end-seed marker')
|
||||
return events.slice(0, -1)
|
||||
}
|
||||
|
||||
describe('SessionStore.fork', () => {
|
||||
it('forks an empty live session as an empty child with lineage metadata', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
@@ -73,7 +83,7 @@ describe('SessionStore.fork', () => {
|
||||
|
||||
const child = sessions.fork(SessionId('parent'), undefined, SessionId('child'))
|
||||
|
||||
expect(child.events).toEqual(source.events)
|
||||
expect(inherited(child)).toEqual(source.events)
|
||||
expect(child.events).not.toBe(source.events)
|
||||
expect(child.events[1]).not.toBe(source.events[1])
|
||||
expect(() => {
|
||||
@@ -97,8 +107,8 @@ describe('SessionStore.fork', () => {
|
||||
|
||||
const child = sessions.fork(source, undefined, SessionId('log-only-child'))
|
||||
|
||||
expect(child.events).toEqual(source.events)
|
||||
expect(child.events.at(-1)).toMatchObject({
|
||||
expect(inherited(child)).toEqual(source.events)
|
||||
expect(inherited(child).at(-1)).toMatchObject({
|
||||
type: 'test/log-only',
|
||||
data: { value: 'after execution' },
|
||||
})
|
||||
@@ -114,7 +124,7 @@ describe('SessionStore.fork', () => {
|
||||
|
||||
const child = sessions.fork(source, firstBoundary, SessionId('child-from-first'))
|
||||
|
||||
expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1))
|
||||
expect(inherited(child)).toEqual(source.events.slice(0, firstBoundary + 1))
|
||||
expect(child.header.seedLength).toBe(firstBoundary + 1)
|
||||
expect(child.deriveMessages()).toEqual([{
|
||||
id: expect.any(String) as unknown,
|
||||
@@ -141,11 +151,32 @@ describe('SessionStore.fork', () => {
|
||||
|
||||
const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`))
|
||||
|
||||
expect(child.events.at(-1)?.type).toBe('turn/end')
|
||||
expect(inherited(child).at(-1)?.type).toBe('turn/end')
|
||||
expect(child.header.seedLength).toBe(source.events.length)
|
||||
}
|
||||
})
|
||||
|
||||
it('marks a bracket the child inherited from a still-running parent', async () => {
|
||||
// The constructor placement's central claim, unreachable from the
|
||||
// persistence load path.
|
||||
const { ctx, sessions } = await setup()
|
||||
const parent = ctx.sessions.create(SessionId('bracket-parent'), { meta: { cwd: '/workspace' } })
|
||||
appendClosedTurn(parent, 1, 'work')
|
||||
const open = parent.append('test/bracket-open', { id: 'op-1' })
|
||||
|
||||
const child = sessions.fork(parent, undefined, SessionId('bracket-child'))
|
||||
|
||||
// Parent: no end-seed event follows the bracket, so its owner treats it as live.
|
||||
expect(parent.events.at(-1)).toBe(open)
|
||||
expect(parent.events.some(event => event.type === 'session/end-seed')).toBe(false)
|
||||
// Child: the same bracket is before end-seed, so it belongs to the seed.
|
||||
const boundary = child.events.at(-1)
|
||||
expect(boundary).toMatchObject({ type: 'session/end-seed' })
|
||||
expect(boundary!.seq).toBeGreaterThan(open.seq)
|
||||
expect(child.firstLiveSeq).toBe(open.seq + 1)
|
||||
expect(inherited(child).at(-1)).toMatchObject({ type: 'test/bracket-open', data: { id: 'op-1' } })
|
||||
})
|
||||
|
||||
it('rejects invalid boundaries before creating a child', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const empty = ctx.sessions.create(SessionId('empty'))
|
||||
|
||||
@@ -382,6 +382,24 @@ describe('session-log invariants', () => {
|
||||
.toThrow(/turn 1 is still open/)
|
||||
})
|
||||
|
||||
it('accepts end-seed whether or not a turn is open', async () => {
|
||||
const { ctx } = await setup()
|
||||
// Balanced seed: between turns.
|
||||
expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
] })).not.toThrow()
|
||||
// Unbalanced seed: inside the open turn, which the relation permits.
|
||||
const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
] })
|
||||
expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed'])
|
||||
// Still open afterwards: the boundary moves no cursor.
|
||||
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.toThrow(/turn 1 is still open/)
|
||||
expect(() => open.append('turn/end', { turn: 1, reason: { kind: 'completed' } })).not.toThrow()
|
||||
})
|
||||
|
||||
it('removes all listeners when the companion is disposed', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
|
||||
@@ -112,7 +112,19 @@ describe('Session properties', () => {
|
||||
const original = build(events)
|
||||
const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
expect(replayed.seq).toBe(original.seq)
|
||||
// A non-empty replay grows by exactly one log-only boundary.
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
expect(replayed.seq).toBe(original.seq === 0 ? 0 : original.seq + 1)
|
||||
}))
|
||||
})
|
||||
|
||||
it('replaying a log that already ends in end-seed adds no further marker', () => {
|
||||
fc.assert(fc.property(logArb, (events) => {
|
||||
const original = build(events)
|
||||
const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events])
|
||||
const twice = new Session(SessionId(`idem-b-${counter++}`), [...once.events])
|
||||
// Lazy resume makes browsing a pickup, so this must not grow per open.
|
||||
expect(twice.events).toEqual(once.events)
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
|
||||
import { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
|
||||
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
|
||||
|
||||
/**
|
||||
@@ -273,3 +273,44 @@ describe('interruptedTurnClosers', () => {
|
||||
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('lastActivityTime', () => {
|
||||
const endSeedAt = (seq: number, time: number): SessionEvent =>
|
||||
({ type: 'session/end-seed', seq, time, data: {} })
|
||||
|
||||
it('has no answer for an empty log', () => {
|
||||
expect(lastActivityTime([])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports the log tail when no boundary is present', () => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
expect(lastActivityTime(events)).toBe(500)
|
||||
})
|
||||
|
||||
it('skips a trailing boundary in favour of the last real work', () => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
endSeedAt(2, 9_000),
|
||||
]
|
||||
// Resumed long after the work, but never worked in again.
|
||||
expect(lastActivityTime(events)).toBe(500)
|
||||
})
|
||||
|
||||
it('reports work appended after end-seed', () => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(1, 0),
|
||||
endSeedAt(1, 9_000),
|
||||
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
expect(lastActivityTime(events)).toBe(9_500)
|
||||
})
|
||||
|
||||
it('has no answer for a log of nothing but boundaries', () => {
|
||||
// Unreachable via the constructor, but the projection is a pure function.
|
||||
expect(lastActivityTime([endSeedAt(0, 1), endSeedAt(1, 2)])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('Session', () => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
|
||||
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
|
||||
expect(replayed.events).toEqual(session.events)
|
||||
expect(replayed.events.slice(0, -1)).toEqual(session.events)
|
||||
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
})
|
||||
@@ -188,7 +188,10 @@ describe('Session', () => {
|
||||
|
||||
const replayed = new Session(SessionId('s3-replay'), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
expect(replayed.seq).toBe(original.seq)
|
||||
// The seed verbatim, plus the end-seed event the constructor appends.
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
expect(replayed.seq).toBe(original.seq + 1)
|
||||
expect(replayed.firstLiveSeq).toBe(original.seq)
|
||||
})
|
||||
|
||||
it('rejects pre-provider request headers and assistant messages on seed/load', () => {
|
||||
@@ -217,7 +220,7 @@ describe('Session', () => {
|
||||
const unrelatedPrimitiveData = {
|
||||
type: 'plugin/event', seq: 0, time: 1, data: null,
|
||||
} as unknown as SessionEvent
|
||||
expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events)
|
||||
expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
|
||||
.toEqual([unrelatedPrimitiveData])
|
||||
})
|
||||
|
||||
@@ -522,7 +525,8 @@ describe('Session', () => {
|
||||
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
|
||||
] as SessionEvent[]
|
||||
const session = new Session(SessionId('seed-ok'), goodSeed)
|
||||
expect(session.events).toHaveLength(3)
|
||||
expect(session.events.slice(0, 3)).toEqual(goodSeed)
|
||||
expect(session.firstLiveSeq).toBe(3)
|
||||
})
|
||||
|
||||
it('reads each seed array entry once so validation and storage use the same event', () => {
|
||||
@@ -546,7 +550,7 @@ describe('Session', () => {
|
||||
const session = new Session(SessionId('seed-entry-snapshot'), seed)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(session.events).toEqual([accepted])
|
||||
expect(session.events.slice(0, 1)).toEqual([accepted])
|
||||
})
|
||||
|
||||
it('reads a nested seed-data getter once and stores its first JSON value', () => {
|
||||
@@ -624,7 +628,7 @@ describe('Session', () => {
|
||||
|
||||
const session = new Session(SessionId('seed-null-prototype'), [event])
|
||||
|
||||
expect(session.events).toEqual([{ ...event }])
|
||||
expect(session.events.slice(0, 1)).toEqual([{ ...event }])
|
||||
})
|
||||
|
||||
it('reads a nested seed-metadata getter once and stores its first JSON value', () => {
|
||||
@@ -1647,6 +1651,7 @@ describe('todo/write event', () => {
|
||||
const replayed = new Session(SessionId('t4-replay'), [...original.events])
|
||||
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
|
||||
.toEqual([{ content: 'only', status: 'completed' }])
|
||||
expect(replayed.seq).toBe(original.seq)
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
expect(replayed.firstLiveSeq).toBe(original.seq)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md
|
||||
README.md: 79badba0b84b27c01f25e9c31b5df78c556411ea
|
||||
README.zh.md: fb3ed08bc9ea546033acac3b577980f500ce243d
|
||||
README.md: 23bc0e8177ad2a778df9522e254bfd5e03a9871f
|
||||
README.zh.md: 1fd4febc1c15acda19e7abfca94079b9585c1972
|
||||
|
||||
@@ -8,6 +8,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by the DeepSeek Harness SDK.` order-−100 opener. Set false only when a compatibility deployment owns the complete system prompt. |
|
||||
| `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
|
||||
| `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `'<unlisted-tools>'` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md). |
|
||||
|
||||
@@ -48,7 +49,7 @@ Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/imple
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas.
|
||||
By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener for a deployment that owns the complete compatibility persona. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas.
|
||||
|
||||
##### Harness identity
|
||||
|
||||
@@ -58,7 +59,7 @@ You are an AI agent powered by the DeepSeek Harness SDK.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Identity is a fixed per-request cost. Persona and plugin text are repeated per request and scale with their rendered content.
|
||||
Identity is a fixed per-request cost when enabled. Persona and plugin text are repeated per request and scale with their rendered content.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
| 键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `includeHarnessIdentity` | `true` | 是否包含固定的 `You are an AI agent powered by the DeepSeek Harness SDK.`、顺序为 −100 的开场白。仅当兼容部署拥有完整系统提示词时设为 false。 |
|
||||
| `persona` | `''` | 全局部署 persona 默认值:唯一由配置创作的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(随附循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 |
|
||||
| `toolOrder` | 无 | 显式的面向模型工具顺序:一个 `ToolSchema.name` 列表,包含一个 `'<unlisted-tools>'` 其余项(`TOOL_ORDER_REST`)。已列工具占据列出的位置;未列工具按名称字典序落在其余项位置。缺席 ⇒ 直接按名称字典序排列。在 `system-prompt/assemble` waterfall(瀑布式事件)之前应用于已收集工具;与段的 `order` 排序一样,它会规范化注册表贡献的内容(注册顺序是插件加载产物),而修改列表的 waterfall 监听器拥有其输出的确定性。配置错误会明确失败:列表没有恰好一个其余项或存在重复项,会在加载时抛出;已列名称没有对应已注册工具,会使每次 `assemble()` 被拒绝;工具提供方返回保留的其余项名称也会被拒绝。在随附循环下,轮次会在任何模型请求前失败。为何采用中心列表而非每插件权重,见[显式面向模型工具顺序](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md)。 |
|
||||
|
||||
@@ -48,7 +49,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。
|
||||
默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅为拥有完整兼容 persona 的部署省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。
|
||||
|
||||
##### Harness 身份
|
||||
|
||||
@@ -58,7 +59,7 @@ You are an AI agent powered by the DeepSeek Harness SDK.
|
||||
|
||||
#### Token 影响
|
||||
|
||||
身份是每次请求的固定成本。Persona 与插件文本在每次请求中重复,成本随渲染内容增长。
|
||||
启用时,身份是每次请求的固定成本。Persona 与插件文本在每次请求中重复,成本随渲染内容增长。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -145,6 +145,8 @@ function compareToolNames(a: ToolSchema, b: ToolSchema): number {
|
||||
|
||||
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
|
||||
export interface Config {
|
||||
/** Include the fixed DeepSeek Harness identity before the deployment persona (default true). */
|
||||
includeHarnessIdentity?: boolean
|
||||
/**
|
||||
* Deployment-wide order-0 persona template. A scoped section named
|
||||
* `deployment:persona` shadows it; `{{variable}}` references are strict.
|
||||
@@ -245,6 +247,7 @@ class PromptLayer implements ScopeLayer {
|
||||
/** Registry service for the prompt inputs assembled before each model step. */
|
||||
export class SystemPrompt extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
includeHarnessIdentity: z.boolean().default(true),
|
||||
persona: z.string().default(''),
|
||||
// Preserve omission because an explicit empty order lacks the rest marker.
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
@@ -260,11 +263,13 @@ export class SystemPrompt extends Service {
|
||||
super(ctx, 'systemPrompt')
|
||||
this.toolOrder = validateToolOrder(config.toolOrder)
|
||||
// Keep harness-owned openers independent of the selected loop plugin.
|
||||
this.section({
|
||||
name: 'harness:identity',
|
||||
order: -100,
|
||||
text: 'You are an AI agent powered by the DeepSeek Harness SDK.',
|
||||
})
|
||||
if (config.includeHarnessIdentity ?? true) {
|
||||
this.section({
|
||||
name: 'harness:identity',
|
||||
order: -100,
|
||||
text: 'You are an AI agent powered by the DeepSeek Harness SDK.',
|
||||
})
|
||||
}
|
||||
this.section({
|
||||
name: 'deployment:persona',
|
||||
order: 0,
|
||||
|
||||
@@ -37,6 +37,18 @@ describe('SystemPrompt', () => {
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(IDENTITY)
|
||||
})
|
||||
|
||||
it('can omit the harness identity for a deployment that owns the complete persona', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, {
|
||||
includeHarnessIdentity: false,
|
||||
persona: 'You are a helpful software engineer assistant.',
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.map(section => section.name)).toEqual(['deployment:persona'])
|
||||
expect(renderPrompt(assembly)).toBe('You are a helpful software engineer assistant.')
|
||||
})
|
||||
|
||||
it('tolerates a schema-bypassing direct construction (persona omitted)', async () => {
|
||||
// ctx.plugin validates + defaults the config first; a direct construction
|
||||
// skips the schema, so the ctor's `?? ''` narrowing is what fires.
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
Reference in New Issue
Block a user