Merge remote-tracking branch 'origin/master' into feat/py-types-code-mode

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	packages/core/tools/README.i18n.yaml
This commit is contained in:
Chinesezjc
2026-08-03 11:08:42 +08:00
91 changed files with 1431 additions and 283 deletions

View File

@@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto'
import { isAbsolute } from 'node:path'
import { Readable, Writable } from 'node:stream'
import Schema from 'schemastery'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
import {
AgentSideConnection,
ndJsonStream,
@@ -368,7 +368,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (result.status === 'rejected') failures.push(result.reason as unknown)
}
if (failures.length > 0) {
throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s)`)
// The production consumer logs this AggregateError through `String`,
// which renders only its message. Embed every per-session diagnostic,
// including nested causes and aggregate members, in that message.
const detail = failures.map(failure => errorChain(failure)).join('; ')
throw new AggregateError(
failures,
`ACP agent teardown failed for ${failures.length} session(s): ${detail}`,
)
}
})()
return quiescing

View File

@@ -96,7 +96,7 @@ describe('ACP connection ownership', () => {
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('awaits every owned session disposal before reporting one failure', async () => {
it('awaits every owned session disposal and reports nested failure reasons', async () => {
harness = await makeBridgeHarness()
const create = harness.ctx.agents.create.bind(harness.ctx.agents)
const releaseSecond = Promise.withResolvers<undefined>()
@@ -110,7 +110,10 @@ describe('ACP connection ownership', () => {
if (created++ === 0) {
handle.dispose = async () => {
await originalDispose()
throw new Error('first session cleanup failed')
throw new AggregateError([
new Error('scope cleanup failed', { cause: new Error('sqlite busy') }),
new Error('hook cleanup failed'),
], 'first session cleanup failed')
}
} else {
handle.dispose = async () => {
@@ -131,7 +134,11 @@ describe('ACP connection ownership', () => {
releaseSecond.resolve(undefined)
await vi.waitFor(() => {
expect(warnings.some(warning => warning.includes('ACP agent teardown failed for 1 session(s)'))).toBe(true)
expect(warnings.some(warning =>
warning.includes(
'ACP agent teardown failed for 1 session(s): '
+ 'first session cleanup failed [scope cleanup failed: sqlite busy; hook cleanup failed]',
))).toBe(true)
expect(harness!.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined()
expect(harness!.ctx.agents.get(SessionId(second.sessionId))).toBeUndefined()
})

View File

@@ -59,6 +59,8 @@ interface CatalogInflight {
readonly promise: Promise<void>
readonly expandableRows: Set<SessionId>
readonly activityRows: Map<SessionId, 'running' | 'inactive'>
/** Removal-time invalidation replayed over the response this request predates. */
parentAvailableOverride: false | undefined
}
type SessionListMutation =
@@ -101,6 +103,8 @@ export class SessionManager {
private readonly addresses = new Map<SessionId, SubagentAddress>()
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
private readonly catalogInflight = new Map<SessionId, CatalogInflight>()
/** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */
private readonly catalogStale = new Set<SessionId>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
@@ -301,22 +305,26 @@ export class SessionManager {
try {
const { result } = await this.api.subagents.list({ parentSessionId })
if (result.ok) {
const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? result.value.parentAvailable
this.catalogs.set(parentSessionId, {
...result.value,
entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows),
parentAvailable,
state: 'ready',
error: null,
})
for (const [childId, address] of this.addresses) {
if (address.parentSessionId !== parentSessionId) continue
this.sessions.get(childId)?.handleSubagentParentAvailable(result.value.parentAvailable)
this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable)
}
} else {
this.catalogs.set(parentSessionId, {
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: previous?.parentAvailable ?? false,
parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? previous?.parentAvailable ?? false,
state: 'error',
error: result.error,
})
@@ -327,16 +335,26 @@ export class SessionManager {
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: previous?.parentAvailable ?? false,
parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? previous?.parentAvailable ?? false,
state: 'error',
error: folded.ok ? null : folded.error,
})
} finally {
this.catalogInflight.delete(parentSessionId)
// Re-arm the trailing pull before the dirty notify: the response the
// caller observed predates the stale-marking change, so the follow-up
// refresh is the only carrier of that change.
if (this.catalogStale.delete(parentSessionId)) void this.refreshSubagents(parentSessionId)
this.notifier.markDirty()
}
})()
this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows, activityRows })
this.catalogInflight.set(parentSessionId, {
promise: operation,
expandableRows,
activityRows,
parentAvailableOverride: undefined,
})
return operation
}
@@ -673,6 +691,29 @@ export class SessionManager {
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
// A pull already in flight was requested before this removal and can
// carry the pre-removal parentAvailable:true, which would resurrect
// the writable editor this invalidation just closed. Replay false over
// that response and queue one trailing refresh so the post-removal
// host truth converges.
const inflightCatalog = this.catalogInflight.get(frame.sessionId)
if (inflightCatalog !== undefined) {
inflightCatalog.parentAvailableOverride = false
this.catalogStale.add(frame.sessionId)
}
// The removed session can no longer be the delivery owner of its
// catalog: invalidate availability immediately. Removal schedules no
// catalog refresh, and without this an addressed child keeps a
// writable editor against a dead continuation owner until an
// unrelated refresh (or forever, for a closed menu).
const ownedCatalog = this.catalogs.get(frame.sessionId)
if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) {
this.catalogs.set(frame.sessionId, { ...ownedCatalog, parentAvailable: false })
}
for (const [childId, address] of this.addresses) {
if (address.parentSessionId !== frame.sessionId) continue
this.sessions.get(childId)?.handleSubagentParentAvailable(false)
}
return
}
case 'host/session-status': {
@@ -724,11 +765,18 @@ export class SessionManager {
for (const session of this.sessions.values()) void session.resync()
}
/** Debounce membership refetches while one parent catalog is open. */
/** Debounce membership refetches while one parent catalog is selected or open. */
private scheduleCatalogRefresh(parentSessionId: SessionId): void {
if (this.catalogDebounce.has(parentSessionId)) return
const timer = setTimeout(() => {
this.catalogDebounce.delete(parentSessionId)
// The in-flight response predates the membership frame that scheduled
// this callback. Queue one post-settlement pull instead of treating an
// ordinary overlapping read as evidence that catalog membership changed.
if (this.catalogInflight.has(parentSessionId)) {
this.catalogStale.add(parentSessionId)
return
}
void this.refreshSubagents(parentSessionId)
}, 50)
this.catalogDebounce.set(parentSessionId, timer)

View File

@@ -529,6 +529,149 @@ describe('subagent catalogs', () => {
{ kind: 'child', id: S2, activity: 'inactive' },
])
})
it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api)
const refresh = manager.refreshSubagents(root)
expect(manager.refreshSubagents(root)).toBe(refresh)
api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
first.resolve(ok({ entries: [], parentAvailable: true }))
await refresh
expect(api.callsOf('subagent.list')).toHaveLength(1)
})
it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => {
vi.useFakeTimers()
try {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api, root)
const refresh = manager.refreshSubagents(root)
// A membership frame arrives while the pull is in flight; the debounced
// refresh it schedules fires 50ms later and is coalesced into the pull —
// which was requested before the new child existed. The stale mark must
// queue one trailing pull carrying the change.
manager.handleHostEnvelope({
rpcId: 'child-added' as never,
payload: {
type: 'host/session-added', sessionId: S2, parentSessionId: root, blank: false,
},
})
await vi.advanceTimersByTimeAsync(50)
api.onSubagentList = () => second.promise
first.resolve(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'older',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await refresh
// The trailing pull is already in flight (kicked synchronously in finally).
second.resolve(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'older',
activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'new child',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
await second.promise
expect(api.callsOf('subagent.list')).toHaveLength(2)
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, label: 'older' },
{ kind: 'child', id: S2, label: 'new child' },
])
} finally {
vi.useRealTimers()
}
})
it('keeps removal invalidation across a stale success and failed trailing pull', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const child = () => ({
kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker',
activity: 'inactive' as const, hasChildren: false,
})
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api)
const refresh = manager.refreshSubagents(root)
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await refresh
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
// The removal lands while a second pull is in flight: the invalidation
// must survive the pre-removal ok response, so one trailing pull runs.
const mid = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => mid.promise
const midRefresh = manager.refreshSubagents(root)
manager.handleHostEnvelope({
rpcId: 'parent-removed-mid-pull' as never,
payload: { type: 'host/session-removed', sessionId: root },
})
const trailing = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => trailing.promise
mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await midRefresh
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} }))
await vi.waitFor(() => {
expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
state: 'error',
parentAvailable: false,
})
})
const rootCalls = api.callsOf('subagent.list')
.filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root)
expect(rootCalls).toHaveLength(3)
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
})
it('invalidates catalog availability when the owning parent is removed', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
await manager.refreshSubagents(root)
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
manager.handleHostEnvelope({
rpcId: 'parent-removed' as never,
payload: { type: 'host/session-removed', sessionId: root },
})
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
})
})
describe('remaining branches', () => {

View File

@@ -24,6 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-primitives",
@@ -40,6 +41,7 @@
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
@@ -49,7 +51,9 @@
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",

View File

@@ -7,7 +7,8 @@ import type {
import {
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { NS } from './locales.ts'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import css from './SubagentCatalogAction.module.css'
@@ -23,7 +24,7 @@ export interface SubagentCatalogInjected {
/** Full props for the session-header catalog action. */
export type SubagentCatalogActionProps =
PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected
PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale<typeof NS>
interface CatalogRowsProps {
parentSessionId: SessionId
@@ -39,11 +40,14 @@ interface CatalogRowsProps {
closeCatalog: () => void
}
function diagnosticReason(entry: Extract<CatalogEntry, { kind: 'diagnostic' }>): string {
function diagnosticReason(
entry: Extract<CatalogEntry, { kind: 'diagnostic' }>,
t: TranslateNS<typeof NS>,
): string {
switch (entry.reason) {
case 'corrupt': return '会话记录损坏'
case 'unsupported': return '子代理记录版本不受支持'
case 'unavailable': return '会话记录暂不可用'
case 'corrupt': return t('diagnostic.corrupt')
case 'unsupported': return t('diagnostic.unsupported')
case 'unavailable': return t('diagnostic.unavailable')
}
}
@@ -54,18 +58,22 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] {
}
/** Compact trailing activity time for a catalog row. */
function relativeTime(updatedAt: number | undefined, now: number): string | undefined {
function relativeTime(
updatedAt: number | undefined,
now: number,
t: TranslateNS<typeof NS>,
): string | undefined {
if (updatedAt === undefined) return undefined
const minute = 60_000
const hour = 60 * minute
const day = 24 * hour
const diff = Math.max(0, now - updatedAt)
if (diff < minute) return '刚刚'
if (diff < hour) return `${Math.floor(diff / minute)}分钟`
if (diff < day) return `${Math.floor(diff / hour)}小时`
if (diff < 30 * day) return `${Math.floor(diff / day)}`
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月`
return `${Math.floor(diff / (365 * day))}`
if (diff < minute) return t('time.justNow')
if (diff < hour) return t('time.minutes', { n: Math.floor(diff / minute) })
if (diff < day) return t('time.hours', { n: Math.floor(diff / hour) })
if (diff < 30 * day) return t('time.days', { n: Math.floor(diff / day) })
if (diff < 365 * day) return t('time.months', { n: Math.floor(diff / (30 * day)) })
return t('time.years', { n: Math.floor(diff / (365 * day)) })
}
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
@@ -98,28 +106,30 @@ function CatalogLoadingRows({
parentSessionId,
summaries,
level,
t,
}: {
parentSessionId: SessionId
summaries: Readonly<Record<SessionId, SessionSummary>>
level: number
t: TranslateNS<typeof NS>
}) {
const children = Object.values(summaries).filter(summary => (
summary.origin === 'subagent' && summary.parentId === parentSessionId
))
if (children.length === 0) return <div className={css.notice}></div>
if (children.length === 0) return <div className={css.notice}>{t('loading.label')}</div>
return children.map(summary => (
<div key={summary.id} className={css.node}>
<div
role="treeitem"
aria-disabled="true"
aria-level={level}
aria-label="正在加载子代理"
aria-label={t('loading.aria')}
className={`${css.row} ${css.disabled} ${css.loadingRow}`}
>
<span className={css.disclosureSpace} />
<StateDot state={summary.running ? 'ongoing' : 'done'} />
<span className={css.content}>
<span className={css.label}></span>
<span className={css.label}>{t('loading.label')}</span>
</span>
</div>
</div>
@@ -129,8 +139,8 @@ function CatalogLoadingRows({
/** Render one catalog level and recurse only through explicitly expanded rows. */
function CatalogRows({
parentSessionId, catalog, catalogs, summaries, expanded, level, now,
openChild, refresh, toggleBranch, closeCatalog,
}: CatalogRowsProps) {
openChild, refresh, toggleBranch, closeCatalog, t,
}: CatalogRowsProps & { t: TranslateNS<typeof NS> }) {
const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
return (
<>
@@ -139,24 +149,25 @@ function CatalogRows({
parentSessionId={parentSessionId}
summaries={summaries}
level={level}
t={t}
/>
)}
{catalog.state === 'error' && (
<div className={css.error}>
<span>{catalog.error?.message ?? '无法加载子代理'}</span>
<span>{catalog.error?.message ?? t('load.error')}</span>
<button
type="button"
className={css.refresh}
onClick={() => { refresh(parentSessionId) }}
>
<IconRefreshOutline14 />
{t('retry')}
</button>
</div>
)}
{catalog.entries.map((entry) => {
if (entry.kind === 'diagnostic') {
const reason = diagnosticReason(entry)
const reason = diagnosticReason(entry, t)
return (
<div key={entry.id} className={css.node}>
<div
@@ -185,12 +196,12 @@ function CatalogRows({
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
const summary = summaries[entry.id]
const label = entry.label ?? entry.id
const mode = entry.mode === 'one-shot' ? '一次性' : '可继续'
const activity = entry.activity === 'running' ? '正在运行' : '当前未运行'
const mode = entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable')
const activity = entry.activity === 'running' ? t('activity.running') : t('activity.inactive')
const secondary = [summary?.title, mode, activity]
.filter(value => value !== undefined)
.join(' · ')
const time = relativeTime(summary?.updatedAt, now)
const time = relativeTime(summary?.updatedAt, now, t)
const open = (): void => {
openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
@@ -235,7 +246,7 @@ function CatalogRows({
type="button"
tabIndex={-1}
className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`}
aria-label={`${isExpanded ? '收起' : '展开'} ${label} 的下级子代理`}
aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label })}
onClick={toggle}
>
<IconChevronRightOutline14 />
@@ -262,6 +273,7 @@ function CatalogRows({
parentSessionId={entry.id}
summaries={summaries}
level={level + 1}
t={t}
/>
)
: (
@@ -277,6 +289,7 @@ function CatalogRows({
refresh={refresh}
toggleBranch={toggleBranch}
closeCatalog={closeCatalog}
t={t}
/>
)}
</div>
@@ -291,10 +304,10 @@ function CatalogRows({
/**
* Render the current session's direct catalog and lazily expanded descendants.
* @param props - session standard props plus catalog navigation actions.
* @returns The action only after a non-empty catalog arrives.
* @returns The action while the catalog is pending or summaries establish descendants.
*/
export function SubagentCatalogAction({
sessionId, useSessions, openChild, refresh, setCatalogOpen,
sessionId, useSessions, openChild, refresh, setCatalogOpen, t,
}: SubagentCatalogActionProps) {
const catalogs = useSessions(state => state.subagentsByParent)
const summaries = useSessions(state => state.byId)
@@ -311,6 +324,20 @@ export function SubagentCatalogAction({
// The catalog can arrive before the session-list baseline; never undercount
// the already-visible direct rows during that short bootstrap window.
const descendantCount = Math.max(healthy.length, descendants.count)
const totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other'
const runningCountKey = descendantCount === 1 ? 'count.running.one' : 'count.running.other'
// Session summaries can announce membership before the descriptor-backed catalog catches up.
// Keep that entry point visible through disabled loading rows; only catalog rows are navigable.
const summaryBackedLoading = descendants.count > 0
&& (catalog === undefined || (catalog.state === 'ready' && catalog.entries.length === 0))
const presentedCatalog: SubagentCatalogSnapshot | undefined = summaryBackedLoading
? {
entries: [],
parentAvailable: catalog?.parentAvailable ?? false,
state: 'loading',
error: null,
}
: catalog
const observeCatalog = (parentSessionId: SessionId, next: boolean): void => {
if (next) observedCatalogs.current.add(parentSessionId)
@@ -375,7 +402,8 @@ export function SubagentCatalogAction({
observedCatalogs.current.clear()
}, [])
const visible = catalog !== undefined && (catalog.state !== 'ready' || catalog.entries.length > 0)
const visible = presentedCatalog !== undefined
&& (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0)
useEffect(() => {
if (visible || !open) return
setOpen(false)
@@ -419,7 +447,7 @@ export function SubagentCatalogAction({
className={css.trigger}
aria-haspopup="tree"
aria-expanded={open}
aria-label={`${descendantCount} 个子代理${descendants.running ? ',正在运行' : ''}`}
aria-label={t(descendants.running ? runningCountKey : totalCountKey, { count: descendantCount })}
onClick={() => { changeOpen(!open) }}
onKeyDown={(event) => {
if (event.key !== 'ArrowDown') return
@@ -431,14 +459,14 @@ export function SubagentCatalogAction({
<span className={css.activitySlot}>
{descendants.running && <StateDot state="ongoing" />}
</span>
<span className={css.count}>{descendantCount} </span>
<span className={css.count}>{t(totalCountKey, { count: descendantCount })}</span>
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
</button>
{open && (
<div className={css.menu} role="tree" aria-label="子代理会话">
<div className={css.menu} role="tree" aria-label={t('tree.aria')}>
<CatalogRows
parentSessionId={sessionId}
catalog={catalog}
catalog={presentedCatalog}
catalogs={catalogs}
summaries={summaries}
expanded={expanded}
@@ -448,6 +476,7 @@ export function SubagentCatalogAction({
refresh={refresh}
toggleBranch={toggleBranch}
closeCatalog={() => { changeOpen(false) }}
t={t}
/>
</div>
)}

View File

@@ -1,4 +1,5 @@
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { NS } from './locales.ts'
import css from './SubagentReadOnlyComposer.module.css'
/** Why a catalog-addressed conversation cannot accept human input. */
@@ -8,7 +9,7 @@ export interface SubagentReadOnlyMatch {
/** Full chain props after the read-only subagent selector accepts the owner currency. */
export type SubagentReadOnlyComposerProps =
PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch }
PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } & PropsLocale<typeof NS>
/**
* Explain why the normal composer is unavailable for an addressed child.
@@ -16,16 +17,14 @@ export type SubagentReadOnlyComposerProps =
* @returns A read-only composer replacement.
*/
export function SubagentReadOnlyComposer({
matched,
}: Pick<SubagentReadOnlyComposerProps, 'matched'>) {
matched, t,
}: Pick<SubagentReadOnlyComposerProps, 'matched' | 't'>) {
const oneShot = matched.reason === 'one-shot'
return (
<div className={css.frame} role="status">
<strong>{oneShot ? '一次性子代理记录' : '此子代理暂时只读'}</strong>
<strong>{t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')}</strong>
<span>
{oneShot
? '一次性任务不支持后续消息,可在这里查看完整执行记录。'
: '父会话当前不在线,重新打开父会话后即可继续发送消息。'}
{t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
</span>
</div>
)

View File

@@ -18,6 +18,15 @@ import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentC
import {
SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
} from './SubagentReadOnlyComposer.tsx'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { en, NS, zh, type SubagentKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Subagent catalog and read-only composer copy. */
'subagent': SubagentKey
}
}
export type {
SubagentCatalogActionProps, SubagentCatalogInjected,
@@ -27,7 +36,7 @@ export type {
} from './SubagentReadOnlyComposer.tsx'
/** Required services for references, conversation slots, and session navigation. */
export const inject = ['slash', 'sessions', 'conversation', 'slots']
export const inject = ['slash', 'sessions', 'conversation', 'slots', 'locale']
/** Claim the composer for one-shot history or an unavailable continuation owner. */
function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
@@ -42,6 +51,7 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries')
const sessions = ctx.sessions
// Child labels live on the session list (parentId lineage + displayTitle),
// not the conversation snapshot — the list store is the zero-RPC candidate feed.
@@ -98,6 +108,7 @@ export function apply(ctx: ClientContext): void {
name: 'conversation.session.header.actions',
id: 'subagent-catalog',
order: 10,
locale: NS,
inject: catalogActions,
}, SubagentCatalogAction),
'ui-subagent: lazy descendant catalog action',
@@ -106,6 +117,7 @@ export function apply(ctx: ClientContext): void {
() => ctx.slots.register({
name: 'conversation.composer',
priority: -10,
locale: NS,
select: selectReadOnlySubagent,
}, SubagentReadOnlyComposer),
'ui-subagent: read-only addressed composer',

View File

@@ -0,0 +1,71 @@
/** `subagent` namespace dictionaries. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'subagent'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'diagnostic.corrupt': '会话记录损坏',
'diagnostic.unsupported': '子代理记录版本不受支持',
'diagnostic.unavailable': '会话记录暂不可用',
'time.justNow': '刚刚',
'time.minutes': '{n}分钟',
'time.hours': '{n}小时',
'time.days': '{n}天',
'time.months': '{n}个月',
'time.years': '{n}年',
'loading.label': '正在加载子代理…',
'loading.aria': '正在加载子代理',
'load.error': '无法加载子代理',
'retry': '重试',
'mode.oneShot': '一次性',
'mode.continuable': '可继续',
'activity.running': '正在运行',
'activity.inactive': '当前未运行',
'branch.collapse': '收起 {label} 的下级子代理',
'branch.expand': '展开 {label} 的下级子代理',
'count.total.one': '{count} 个子代理',
'count.total.other': '{count} 个子代理',
'count.running.one': '{count} 个子代理,正在运行',
'count.running.other': '{count} 个子代理,正在运行',
'tree.aria': '子代理会话',
'readonly.oneShot.title': '一次性子代理记录',
'readonly.title': '此子代理暂时只读',
'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。',
'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。',
} as const
/** English dictionary, key-identical to the Chinese source of truth. */
export const en: Record<SubagentKey, string> = {
'diagnostic.corrupt': 'corrupted session record',
'diagnostic.unsupported': 'unsupported subagent record version',
'diagnostic.unavailable': 'session record temporarily unavailable',
'time.justNow': 'just now',
'time.minutes': '{n}m',
'time.hours': '{n}h',
'time.days': '{n}d',
'time.months': '{n}mo',
'time.years': '{n}y',
'loading.label': 'Loading subagents…',
'loading.aria': 'Loading subagents',
'load.error': 'Unable to load subagents',
'retry': 'Retry',
'mode.oneShot': 'one-shot',
'mode.continuable': 'continuable',
'activity.running': 'running',
'activity.inactive': 'not running',
'branch.collapse': 'Collapse {label} descendants',
'branch.expand': 'Expand {label} descendants',
'count.total.one': '{count} subagent',
'count.total.other': '{count} subagents',
'count.running.one': '{count} subagent running',
'count.running.other': '{count} subagents running',
'tree.aria': 'Subagent sessions',
'readonly.oneShot.title': 'One-shot subagent record',
'readonly.title': 'This subagent is read-only for now',
'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.',
'readonly.body': 'The parent session is offline; reopen it to continue sending messages.',
}
/** Key domain of the `subagent` namespace (zh is the source of truth). */
export type SubagentKey = keyof typeof zh

View File

@@ -18,6 +18,7 @@ import {
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
import {
SubagentCatalogAction, type SubagentCatalogInjected,
} from '../src/client/SubagentCatalogAction.tsx'
@@ -85,6 +86,7 @@ async function fullBench(sessions: SessionSummary[]) {
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
ctx.provide('sessions', face)
await provideSlotFaces(ctx)
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
await ctx.plugin({ inject: [...inject], apply }).await()
return { source: captured!, face, ctx }
}
@@ -111,7 +113,7 @@ const req = (query: string) =>
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots'])
expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots', 'locale'])
})
it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => {
@@ -119,6 +121,7 @@ describe('apply', () => {
await ctx.plugin(SlashService).await()
ctx.provide('sessions', sessionsWith(FAMILY))
await provideSlotFaces(ctx)
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const slash = ctx.get('slash') as SlashService

View File

@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type {
SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -8,6 +9,7 @@ import {
SubagentCatalogAction, type SubagentCatalogActionProps,
} from '../src/client/SubagentCatalogAction.tsx'
import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(() => {
cleanup()
@@ -17,6 +19,7 @@ afterEach(() => {
const PARENT = 'parent' as SessionId
const CHILD = 'child' as SessionId
const GRANDCHILD = 'grandchild' as SessionId
const t: SubagentCatalogActionProps['t'] = makeTranslate(zh)
function catalog(over: Partial<SubagentCatalogSnapshot> = {}): SubagentCatalogSnapshot {
return {
@@ -69,6 +72,7 @@ function props(
openChild: vi.fn(),
refresh: vi.fn(),
setCatalogOpen: vi.fn(),
t,
} as unknown as SubagentCatalogActionProps
}
@@ -154,6 +158,24 @@ describe('SubagentCatalogAction', () => {
expect(input.setCatalogOpen).toHaveBeenLastCalledWith(PARENT, false)
})
it('selects singular count keys for one descendant', () => {
const base = props(catalog({
entries: [{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}],
}), {}, {
[CHILD]: {
...summary(CHILD, Date.now()), parentId: PARENT, origin: 'subagent', running: true,
},
})
const translate = vi.fn(base.t)
render(<SubagentCatalogAction {...base} t={translate} />)
expect(translate).toHaveBeenCalledWith('count.running.one', { count: 1 })
expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 })
})
it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => {
const input = props(catalog())
render(<SubagentCatalogAction {...input} />)
@@ -401,6 +423,32 @@ describe('SubagentCatalogAction', () => {
expect(failed.refresh).toHaveBeenCalledWith(PARENT)
})
it('keeps known descendants reachable while their catalog is absent or stale-empty', () => {
const second = 'child-2' as SessionId
const summaries = {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
},
[second]: {
...summary(second, 1), parentId: PARENT, origin: 'subagent' as const, running: true,
},
}
const absent = props(undefined, {}, summaries)
const view = render(<SubagentCatalogAction {...absent} />)
const trigger = screen.getByRole('button', { name: '2 个子代理,正在运行' })
fireEvent.click(trigger)
expect(absent.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
expect(absent.openChild).not.toHaveBeenCalled()
const staleEmpty = props(catalog({ entries: [] }), {}, summaries)
view.rerender(<SubagentCatalogAction {...staleEmpty} />)
expect(screen.getByRole('button', { name: '2 个子代理,正在运行' })).toBeTruthy()
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
expect(staleEmpty.openChild).not.toHaveBeenCalled()
})
it('renders empty loading and fallback error states without focusable rows', async () => {
const loading = props(catalog({ entries: [], state: 'loading' }))
const view = render(<SubagentCatalogAction {...loading} />)
@@ -454,12 +502,12 @@ describe('SubagentCatalogAction', () => {
describe('SubagentReadOnlyComposer', () => {
it('explains the exact missing-parent recovery path', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'parent-unavailable' }} />)
render(<SubagentReadOnlyComposer matched={{ reason: 'parent-unavailable' }} t={t} />)
expect(screen.getByRole('status').textContent).toContain('父会话当前不在线')
})
it('explains that one-shot histories never accept follow-ups', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'one-shot' }} />)
render(<SubagentReadOnlyComposer matched={{ reason: 'one-shot' }} t={t} />)
expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息')
})
})

View File

@@ -11,6 +11,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},

View File

@@ -1607,6 +1607,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AgentOptions',
declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}',
},
{
name: 'AgentSetup',
declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise<AgentSetupCommit | void> | void;',
},
{
name: 'AgentSetupCommit',
declaration: 'export interface AgentSetupCommit {\n commit(): void;\n}',
},
{
name: 'AgentStatus',
declaration: 'export type AgentStatus = \'idle\' | \'running\';',
@@ -1833,7 +1841,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CreateAgentOptions',
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}',
},
{
name: 'CreateGoalRequest',
@@ -2337,7 +2345,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ResumeAgentOptions',
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}',
},
{
name: 'SandboxEnforcement',

View File

@@ -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/agent-loop/README.md
README.md: c71b350adfe06a19d4c24cb7e67de895a662bd87
README.zh.md: d30dfc85e1597a8e193019cc23f4c7c39c991776
README.md: 2ce85071c4b7408adb4ee05291c499ec642be114
README.zh.md: bc78c02fc046f3bb5820f89bae5a90b26b5a8ced

View File

@@ -10,7 +10,7 @@ This is the only package in the harness that contains concrete loop logic. Every
### Public API
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; synchronously invoke its optional publication commit; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Its optional commit revalidates mutable provisioning after every setup await and immediately before registry entry; a throw rolls the private transaction back without publishing either id. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
@@ -20,8 +20,8 @@ Each agent and its session share one caller-chosen `SessionId`, assumed globally
`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({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. 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). `signal` is creation-only. Returns an `AgentHandle`.
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits unpublished setup, invokes its optional synchronous commit at the publication boundary, and then enters both registries; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history under the same id, await setup against a fresh unpublished agent scope, invoke its optional synchronous commit, then use the same rollback-covered publication sequence. 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). `signal` is creation-only. Returns an `AgentHandle`.
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.

View File

@@ -10,7 +10,7 @@
### 公开 API
创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup进入两个注册表依次宣告 `session/created``agent/created`;发出 `agent/session-start`此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。普通的类型化身份与选项输入遵循只读契约以借用方式传入seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载setup发布并在返回的 handle 可见前分离。
创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup同步调用其可选的发布提交;进入两个注册表;依次宣告 `session/created``agent/created`;发出 `agent/session-start`此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。其可选提交会在所有 setup 的 await 均结算后、进入注册表之前立即重新校验可变的配置状态;若其抛出异常,则回滚私有事务且不发布任何一个 id。普通的类型化身份与选项输入遵循只读契约以借用方式传入seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载setup发布并在返回的 handle 可见前分离。
调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)``resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle dispose资源释放或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。
@@ -20,8 +20,8 @@
`AgentLoop` 还实现 `AgentFactory` seam并通过 `ctx.agents.setFactory(this)` 注册自身,因此插件会通过接口 `ctx.agents` 创建/恢复 agent
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup 事务,然后才返回`meta` 携带 cwd谱系seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)使用同一 id 注册 agent重建历史,然后针对全新且尚未发布的 agent 作用域等待 setup再执行受回滚保护发布。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup,在发布边界调用其可选的同步提交,然后进入两个注册表`meta` 携带 cwd谱系seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)同一 id 重建历史,针对全新且尚未发布的 agent 作用域等待 setup调用其可选的同步提交,然后使用相同的受回滚保护发布序列。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`
配置驱动的 `ctx.agentLoop.create()` 路径让循环 fiber 拥有其 agent该路径会丢弃 handle。对于以编程方式创建的 agenthandle 持有者是唯一面向消费方的 teardown 能力AgentLoop 提供方卸载是一条独立的结构化 teardown 边,而不是向应用代码公开的另一个 handle。

View File

@@ -558,7 +558,10 @@ export class AgentLoop extends Service implements AgentFactory {
const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal)
const published = (async () => {
try {
await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId)
const setupCommit = await raceAbort(
options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId,
)
setupCommit?.commit()
return prepared.publish('startup')
} catch (error: unknown) {
await prepared.dispose()
@@ -617,7 +620,8 @@ export class AgentLoop extends Service implements AgentFactory {
})
const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal)
try {
await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
setupCommit?.commit()
return prepared.publish('resume')
} catch (error: unknown) {
await prepared.dispose()

View File

@@ -291,6 +291,13 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
setupStarted.resolve(undefined)
await gate.promise
order.push('setup:end')
return {
commit: () => {
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
order.push('setup:commit')
},
}
},
})
@@ -304,6 +311,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
expect(order).toEqual([
'setup:start',
'setup:end',
'setup:commit',
'session/created',
'setup-listener:session/created',
'agent/created',
@@ -359,6 +367,33 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx.fiber.dispose()
})
it('resume setup commit rejection publishes nothing and releases the identity', async () => {
const sessionId = SessionId('resume-setup-commit-reject')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
await expect(ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
setup: () => ({
commit: () => { throw new Error('resume setup commit failed') },
}),
})).rejects.toThrow('resume setup commit failed')
expect(published).toEqual([])
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
const retry = await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
await retry.dispose()
await ctx.fiber.dispose()
})
it('owner unload aborts resume setup and cannot publish after the callback settles', async () => {
const sessionId = SessionId('resume-setup-owner-unload')
const root = await persistSession(sessionId)

View File

@@ -264,6 +264,13 @@ describe('agent scope lifecycle', () => {
setupStarted.resolve(undefined)
await gate.promise
order.push('setup:end')
return {
commit: () => {
expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined()
order.push('setup:commit')
},
}
},
})
await setupStarted.promise
@@ -276,6 +283,7 @@ describe('agent scope lifecycle', () => {
expect(order).toEqual([
'setup:start',
'setup:end',
'setup:commit',
'session/created',
'setup-listener:session/created',
'agent/created',

View File

@@ -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/agent/README.md
README.md: 4c6a6dd95541cfa559e95858fede01d7cd76637f
README.zh.md: 07bf887c557b410005bbe0fa1a988e63a765ad99
README.md: 98421aa6de3d6778702665854ed723507e933028
README.zh.md: bfc8d68a9656a29a809de0848986e4ee9eb3fe7c

View File

@@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver
### Public API
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup may return an `AgentSetupCommit`; after every setup await settles, the factory invokes its synchronous `commit()` immediately before registry entry, and a throw rolls the private transaction back without publishing either id. Setup remains trusted, composition-only same-process code: drive the agent only after creation resolves.
`AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control.
@@ -39,8 +39,8 @@ The scope carries the `Agent` itself and is process-local. Ambient presence is n
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories.
- `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, invoke its optional synchronous commit, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, invoke its optional synchronous commit, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, awaits its exit, unregisters the agent, removes its session from the store, and finally unwinds its scoped world. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.

View File

@@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事
### 公开 API
带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent。通过它注册工具变量监听器只对该 agent 生效,并在 dispose资源释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方模型推理reasoning强度选择将路由应用到提示词变量并将完整目标应用到一个步骤的请求路由如果没有选定推理强度则会清除继承的推理强度使该目标使用适配器提供方默认值。`CreateAgentOptions.setup(agentCtx)``ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent。通过它注册工具变量监听器只对该 agent 生效,并在 dispose资源释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方模型推理reasoning强度选择将路由应用到提示词变量并将完整目标应用到一个步骤的请求路由如果没有选定推理强度则会清除继承的推理强度使该目标使用适配器提供方默认值。`CreateAgentOptions.setup(agentCtx)``ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时组合其带作用域的世界。Setup 可以返回一个 `AgentSetupCommit`;所有 setup 的 await 均结算后,工厂会在进入注册表前立即调用其同步 `commit()`,若其抛出异常,则回滚私有事务且不发布任何一个 id。Setup 仍是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
`AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header并应用到每次对话模型请求显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。
@@ -39,8 +39,8 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事
Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,并通过 `setFactory` 注册。这样,创建功能留在 `dsh-agent` 接口上消费方UI、ACP 桥接层)可以面向 `ctx.agents` 编程,而不依赖具体循环包。注册表会把已经 traced 的 Service 规范化为具体目标,并通过调用方上下文重新 trace 每次调用;这既避免嵌套 Cordis shadow也会把显式、绑定调用方的 `ownerCtx` 传给普通工厂。
- `ctx.agents.setFactory(factory: AgentFactory): () => void`注册创建工厂循环在构造时调用。第二个工厂会导致抛出dispose 时清空槽位。
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`:创建会话和 agent在不发布的情况下等待可选 setup然后通过最终的 `SessionStore.enter()``AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID多个操作可以进行准备但只有一个能进入每个失败方都会回滚其私有作用域会话驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup并在返回 handle 前分离;之后的取消使用 `handle.dispose()``agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`:创建会话和 agent在不发布的情况下等待可选 setup调用其可选的同步提交,然后通过最终的 `SessionStore.enter()``AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID多个操作可以进行准备但只有一个能进入每个失败方都会回滚其私有作用域会话驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup并在返回 handle 前分离;之后的取消使用 `handle.dispose()``agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup调用其可选的同步提交,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`。Disposer 是一项 **消费方能力**;仅持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 和已注册工厂提供方是结构化共同拥有者:调用方卸载会强制结构化所有权,而工厂卸载必须停止旧实例,因为它们的作用域依赖范围属于该提供方。任意拥有者调用 `dispose()` 都会到达同一个记忆化完全停稳边界:它停止循环,等待循环退出,注销 agent从存储中移除其会话最后撤销其作用域世界。`ctx.agents.get(id)` 仍返回裸 `Agent`ACP 桥接层与进程内 subagent 后端持有消费方 handle而配置创建的 agent 已由循环 fiber 拥有。

View File

@@ -36,6 +36,27 @@ declare module 'cordis' {
}
}
/**
* Synchronous finalizer returned by unpublished Agent setup when its
* contributions need validation at the exact publication commit point.
*/
export interface AgentSetupCommit {
/**
* Validate and commit the prepared setup immediately before publication.
* @throws when publication must roll the unpublished Agent back.
*/
commit(): void
}
/**
* Compose an unpublished Agent scope and optionally return its publication commit.
* @param agentCtx - unpublished Agent scope.
* @returns an optional synchronous commit invoked after setup awaits settle and immediately before publication.
*/
export type AgentSetup = (
agentCtx: Context,
) => AgentSetupCommit | Promise<AgentSetupCommit | void> | void
/**
* Options for programmatically creating an agent through the registry factory
* ({@link AgentRegistry.create}). The caller supplies the single live
@@ -80,17 +101,21 @@ export interface CreateAgentOptions {
* Creation-time composition of the agent's scoped world. The factory awaits
* setup after minting `agentCtx` but BEFORE inserting or announcing either
* the session or agent, so observers can never see a partially configured
* world. Everything registered through `agentCtx` (scoped tools, prompt
* sections/variables, `restrict()`, listeners, awaited child plugins) exists
* before `session/created`, `agent/created`, `agent/session-start`, and the
* first prompt assembly. A throw/rejection or owner disposal rolls the scope
* back without publishing either id.
* world. Setup may return an {@link AgentSetupCommit}; the factory invokes its
* synchronous `commit()` after every setup await settles and immediately
* before registry publication. This lets mutable provisioning revalidate at
* the exact publication boundary. Everything registered through `agentCtx`
* (scoped tools, prompt sections/variables, `restrict()`, listeners, awaited
* child plugins) exists before `session/created`, `agent/created`,
* `agent/session-start`, and the first prompt assembly. A setup
* throw/rejection, commit throw, or owner disposal rolls the scope back
* without publishing either id.
*
* **Setup composes, it never drives**: the callback is trusted same-process
* code and receives the full scoped context, so this is a contract rather
* than a runtime restriction. Drive the agent only after creation resolves.
*/
readonly setup?: (agentCtx: Context) => Promise<void> | void
readonly setup?: AgentSetup
}
/**
@@ -108,12 +133,12 @@ export interface ResumeAgentOptions {
* Resume-time composition of the agent's fresh scoped world. Persistence is
* loaded first; the factory then mints `agentCtx` and awaits setup while the
* reconstructed session and agent remain unpublished. The callback has the
* same trusted composition-only contract as
* {@link CreateAgentOptions.setup}: all registrations exist before either
* creation announcement, and rejection or owner disposal rolls the
* transaction back without publishing either id.
* same trusted composition-only contract and optional synchronous
* publication commit as {@link CreateAgentOptions.setup}: all registrations
* exist before either creation announcement, and rejection, commit failure,
* or owner disposal rolls the transaction back without publishing either id.
*/
readonly setup?: (agentCtx: Context) => Promise<void> | void
readonly setup?: AgentSetup
}
/**
@@ -144,9 +169,9 @@ export interface AgentHandle {
export interface AgentFactory {
/**
* Create a new agent on a caller-supplied session id. Async because creation
* awaits unpublished setup, inserts both session and agent, emits their
* creation notifications in order, emits `agent/session-start`, and only
* then starts the loop. The sequence is
* awaits unpublished setup, invokes its optional synchronous commit, inserts
* both session and agent, emits their creation notifications in order, emits
* `agent/session-start`, and only then starts the loop. The sequence is
* rollback-covered, but notifications delivered before a later listener
* failure remain observable; every agent or session creation announcement
* that began is paired by `agent/disposed` or `session/disposed` during
@@ -165,8 +190,8 @@ export interface AgentFactory {
* Load a persisted session and resume an agent on it. Async because it awaits
* both `ctx.sessionPersistence.load` and the optional unpublished setup
* transaction; must be called after that service exists (consumers inject
* `sessionPersistence`). Publication follows the same ordered boundary as
* {@link createAgent}.
* `sessionPersistence`). Publication follows the same setup-commit and
* ordered boundary as {@link createAgent}.
* @param ownerCtx - caller-bound context that owns load, setup, and the live handle.
* @param options - persisted identity, configuration, and optional setup.
* @returns the owned handle after setup, both announcements, and loop start complete.

View File

@@ -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/tools/README.md
README.md: e055fac61d31e1320b051753092e9b874f62a927
README.zh.md: edfe2032fbe00a66d1a0460a044823723dbe6796
README.md: f561a08bbc9645ea1bc127eedb04d2249a60a156
README.zh.md: a8e7f9579e32e5cd81f6a87cee52d4be6f059106

View File

@@ -43,7 +43,7 @@ The live registry pipeline has three transformable waterfalls, then the definiti
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token.
- `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.
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. It defers one context until the tool's final result reaches the loop — typically a nested-dispatch context ferried by a composite tool, or a fresh plugin-sourced instruction minted by a leaf tool (`tool-goal`'s wrap-up) — 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 identified `UserMessage` 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.

View File

@@ -43,7 +43,7 @@ tools:
- `ToolExecutionInput`:调用方提供的调用描述:`{ callId, name, arguments, signal, agent?, parent? }``signal` 必填且只读,调用方可以将外层执行的不透明 token 作为 `parent` 传入,但绝不能选择新执行自身的 token。
- `ToolExecutionToken`:注册表分配的全新带品牌 `Symbol`。它只支持通过相等性进行关联,绝不会跨越模型、日志或 worker 边界。
- `ToolExecution`:只读流水线视图:不可变的 `{ token, callId, name, arguments, signal, agent?, parent? }`;注册表会另行保留并重新融合调用方的原始信号。`ToolDispatchExecution` 是仅供 `tools/execute` 使用的视图,其必填信号可变,因此包装层可以替换并还原它,但不能删除它。嵌套调用的 `parent``ToolExecutionToken`,而不是执行对象。
- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。组合工具借此把嵌套分发产生的上下文传递到外层结果,即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。
- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`它把一条上下文推迟到该工具的最终结果抵达循环时——通常是组合工具转运的嵌套分发上下文,也可以是叶子工具铸造的全新插件来源指令(如 `tool-goal` 的收尾注入)——即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。
- `ToolExecutionResult`:可辨识的执行局部结果。成功形态为 `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`;失败形态为 `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }`,且不含值。调用身份保留在不可变的 `ToolExecution` 上。注册表会在呈现前快照、验证并冻结规范值,随后在最终观测前实体化持久呈现字段。`ToolFailure.info` 携带内部的 `{ name, code }`,用于表示 `HarnessError``additionalContexts` 会保留每个通过延迟或 post-execute 加入且带标识的 `UserMessage`,供循环在结果后按 FIFO 顺序处理。
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`。该类型有意不提供输入改写;`ask` 在挂载 [`ctx.approval`](../../ui/user-approval/README.md) 时由它处理,否则退化为拒绝。
- `PostToolDecision`:接受决定可以替换 `content``value`(不能同时替换),并可附加 `additionalContexts`;阻止决定会把反馈变成无值失败。替换内容会保留规范值和元数据。替换值会重新验证,并重新呈现内容/元数据。接受决定会先保留工具延迟的上下文,再附加决定上下文;阻止决定会丢弃工具延迟的上下文,只公开阻止决定显式提供的上下文。

View File

@@ -360,15 +360,18 @@ export interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {
/**
* Runtime context handed to a tool implementation after the registry has
* accepted a {@link ToolExecution}. A composite tool uses
* {@link deferContext} to ferry context produced by nested dispatches back to
* the outer result; the loop appends it only after the outer `tool/result`.
* accepted a {@link ToolExecution}. {@link deferContext} attaches context to
* this execution's own result — a composite tool ferries nested-dispatch
* context back to the outer result, and a leaf tool may mint a fresh
* plugin-sourced instruction; the loop appends it only after the
* `tool/result`.
*/
export interface ToolRunContext extends ToolExecution {
/**
* Defer one nested-dispatch context until this tool's final result reaches
* the agent loop. Contexts retain their individual source and metadata and
* are emitted in call order.
* Defer one context — typically a nested-dispatch context ferried by a
* composite tool, or a fresh plugin-sourced instruction — until this tool's
* final result reaches the agent loop. Contexts retain their individual
* source and metadata and are emitted in call order.
*/
deferContext(context: UserMessage): void
/**

View File

@@ -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/goal/tool-goal/README.md
README.md: aaed61dd517aeb2f94efa22c34c64d1068155d46
README.zh.md: 5365b64ef65fb3d3f00e19357327479ebd8285a8
README.md: 2fa80c2e5fa3d675a48fc18506635fd811ac8f80
README.zh.md: c6c39e3cc739fb39a4a36080db5246e7c7349147

View File

@@ -14,7 +14,7 @@ All calls are exclusive, so a model-ordered batch observes earlier mutations and
All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON.
An autonomous goal round that successfully reports `complete` or `blocked` marks that tool execution with `concludeTurn()` so the physical turn stops after the step. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
An autonomous goal round that successfully reports `complete` or `blocked` defers one wrap-up context onto that tool result: an injected instruction telling the model to write a final closing message to the user and call no more tools, after which the turn ends through the ordinary no-tool-calls stop. Direct-human mutations receive no instruction: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
## Authority
@@ -61,11 +61,11 @@ Prefix-stable while the plugin scope, configured threshold, and guidance text ar
#### What the model sees
The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `<goal_state>` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority.
The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `<goal_state>` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. A goal-round `complete` or `blocked` result additionally injects one `<goal_complete>`/`<goal_blocked>` wrap-up instruction that asks for a grounded closing message to the user without further tool calls.
#### Token effect
Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction.
Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. A goal-round terminal update adds the injected wrap-up instruction and one further model request for the closing message — once per goal lifecycle, not per round.
#### KV Cache effect

View File

@@ -14,7 +14,7 @@
3 个规范值都与已经渲染给 Native 调用方的紧凑 JSON 一致:`{ goal: null }``{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`。因此,编程消费方无需解析渲染后的 JSON即可收到相同领域结构。
自主 Goal Round 成功报告 `complete``blocked` 时,会`concludeTurn()` 标记该次工具执行,使物理轮次在该步骤后停止。人类直接变更不会导致这种停止assistant 可以确认变更,循环仍可接收并发的人类 steering中途引导
自主 Goal Round 成功报告 `complete``blocked` 时,会在该次工具结果上附带一条收尾注入指令,要求模型面向用户写出最终收尾消息、不再调用工具,之后轮次经由常规的无工具调用停止路径结束。人类直接变更不会收到这条指令assistant 可以确认变更,循环仍可接收并发的人类 steering中途引导
## 权限
@@ -61,11 +61,11 @@ Use goal tools for one long-running completion objective in the current session.
#### 模型看到的内容
生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `<goal_state>` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。
生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `<goal_state>` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。Goal Round 的 `complete`/`blocked` 结果还会额外注入一条 `<goal_complete>`/`<goal_blocked>` 收尾指令,要求模型向用户写出有依据的收尾消息且不再调用工具。
#### Token 影响
固定 schema 成本加上每次调用的一条紧凑结果。变更还会保留领域快照直到压缩compaction
固定 schema 成本加上每次调用的一条紧凑结果。变更还会保留领域快照直到压缩compactionGoal Round 的终态更新会增加注入的收尾指令和一次额外的模型请求用于收尾消息——每个 goal 生命周期一次,而非每轮一次。
#### KV Cache 影响

View File

@@ -8,7 +8,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import { GoalId } from '@deepseek-ai/dsh-goal'
import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -17,6 +17,7 @@ import {
goalToolExecution,
requireDirectHuman,
} from './authority.ts'
import { renderWrapupContext } from './wrapup.ts'
export const name = 'tool-goal'
export const inject = ['agents', 'goals', 'tools', 'systemPrompt']
@@ -309,7 +310,14 @@ export function apply(ctx: Context, config: Config): void {
code: 'model-reported',
message: args.blocked_reason as string,
})
if (authority.kind === 'goal-round') exec.concludeTurn()
if (authority.kind === 'goal-round') {
exec.deferContext(createUserMessage({
content: args.action === 'complete'
? renderWrapupContext(goal.objective)
: renderWrapupContext(goal.objective, args.blocked_reason as string),
source: { kind: 'plugin', plugin: 'tool-goal' },
}))
}
return Promise.resolve(goalValue(goal))
},
presentCall: args => present(

View File

@@ -0,0 +1,41 @@
/** Model-visible wrap-up instruction for a terminal autonomous goal update. */
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
const GROUNDING =
'Report only what earlier rounds and tool results in this session actually establish; '
+ 'when a detail is not in the session, say so instead of inventing it. '
/**
* Render the closing-message instruction injected after an autonomous goal
* round reports `complete` or `blocked`, replacing the former hard turn stop
* so the model still addresses the user once before the turn ends.
* @param objective - the terminal goal's objective, echoed for grounding.
* @param blockedReason - the validated report for `blocked`; omitted for `complete`.
* @returns a fresh one-block context for `ToolRunContext.deferContext()`.
*/
export function renderWrapupContext(objective: string, blockedReason?: string): ContentBlock[] {
const heading = `Objective: ${JSON.stringify(objective)}\n`
const text = blockedReason === undefined
? '<goal_complete>\n'
+ heading
+ 'The goal is marked complete and this autonomous run is ending. Write the closing '
+ 'message to the user now: state the outcome, summarize what was done and how it was '
+ 'verified, and point to the concrete results (files, commits, or other artifacts). '
+ GROUNDING
+ 'Note anything the user should review or do next. Address the user directly. Do not '
+ "call any more tools in this run; further work waits for the user's next instruction.\n"
+ '</goal_complete>'
: '<goal_blocked>\n'
+ heading
+ `Blocked: ${JSON.stringify(blockedReason)}\n`
+ 'The goal is marked blocked and this autonomous run is ending. Write the closing '
+ 'message to the user now: state what has been completed so far, describe the concrete '
+ 'blocking condition and what you tried, and say exactly what you need from the user to '
+ 'continue. '
+ GROUNDING
+ 'Address the user directly. Do not call any more tools in this run; further work '
+ "waits for the user's next instruction.\n"
+ '</goal_blocked>'
return [{ type: 'text', text }]
}

View File

@@ -347,7 +347,7 @@ describe('goal tool state transitions', () => {
expect(goal).toMatchObject({ phase: 'active', revision: 4 })
})
it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => {
it('injects one wrap-up instruction for an autonomous completion but leaves a human pause interactive', async () => {
const { ctx, root } = await harness()
const humanTurn = openTurn(root, { kind: 'user' })
const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' })
@@ -356,6 +356,7 @@ describe('goal tool state transitions', () => {
}, root.agent)
expect(resultGoal(paused)).toMatchObject({ phase: 'paused' })
expect(paused.concludesTurn).toBeUndefined()
expect(paused.additionalContexts).toBeUndefined()
const resumed = resultGoal(await execute(ctx, 'update_goal', {
goal_id: created.id, revision: 2, action: 'resume',
}, root.agent))
@@ -368,7 +369,27 @@ describe('goal tool state transitions', () => {
goal_id: created.id, revision: resumed['revision'], action: 'complete',
}, root.agent)
expect(resultGoal(complete)).toMatchObject({ phase: 'complete' })
expect(complete.concludesTurn).toBe(true)
expect(complete.concludesTurn).toBeUndefined()
const contexts = complete.additionalContexts ?? []
expect(contexts).toHaveLength(1)
expect(contexts[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-goal' })
const block = contexts[0]?.content[0]
if (block?.type !== 'text') throw new Error('expected one text wrap-up block')
expect(block.text).toContain('<goal_complete>')
expect(block.text).toContain('"pause cleanly"')
expect(block.text).toContain("Do not call any more tools in this run; further work waits for the user's next instruction.")
})
it('completes without a wrap-up instruction under direct human authority', async () => {
const { ctx, root } = await harness()
openTurn(root, { kind: 'user' })
const created = ctx.goals.create(root.agent, { objective: 'finish now' })
const complete = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'complete',
}, root.agent)
expect(resultGoal(complete)).toMatchObject({ phase: 'complete' })
expect(complete.concludesTurn).toBeUndefined()
expect(complete.additionalContexts).toBeUndefined()
})
it('rearms a restored active goal only after a new direct human prompt', async () => {
@@ -550,6 +571,14 @@ describe('goal tool state transitions', () => {
blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' },
roundsStarted: 3,
})
expect(blocked.concludesTurn).toBeUndefined()
const contexts = blocked.additionalContexts ?? []
expect(contexts).toHaveLength(1)
const block = contexts[0]?.content[0]
if (block?.type !== 'text') throw new Error('expected one text wrap-up block')
expect(block.text).toContain('<goal_blocked>')
expect(block.text).toContain('The required credential is still unavailable.')
expect(block.text).toContain("Do not call any more tools in this run; further work waits for the user's next instruction.")
})
it('lets direct human authority block before the model threshold', async () => {
@@ -570,5 +599,7 @@ describe('goal tool state transitions', () => {
},
roundsStarted: 0,
})
expect(blocked.concludesTurn).toBeUndefined()
expect(blocked.additionalContexts).toBeUndefined()
})
})

View File

@@ -1034,8 +1034,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
/** Whether the session's own suffix carries the durable subagent discriminator. */
function hasSubagentDescriptor(session: Pick<Session, 'events' | 'header'>): boolean {
const ownStart = session.header.seedLength ?? 0
return session.events.slice(ownStart).some(event => event.type === 'subagent/descriptor')
const events = session.events
// Indexed scan from the own-suffix start: slicing copies the whole suffix
// on every Agent-bound RPC, including each `session.prompt` on long
// transcripts.
for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) {
if (events[index]?.type === 'subagent/descriptor') return true
}
return false
}
/**
@@ -1076,13 +1082,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return inspected
}
async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
const attached = ctx.sessions.get(sessionId)
/**
* Resolve one live registered identity through the subagent-ownership
* fence: subagent-owned agents answer `agent-busy`, plain agents pass.
* Fences the live agent's own session rather than trusting a
* "registered ⇒ attached-store" invariant — a registered subagent whose
* session is ever absent from the attached store must still not be handed
* out through generic Host routing. `undefined` means no live agent.
*/
function fencedLiveAgent(sessionId: SessionId): { agent: Agent } | { error: RpcError } | undefined {
const live = ctx.agents.get(sessionId)
if (attached !== undefined && hasSubagentOwner(attached, live)) {
if (live === undefined) return undefined
if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) }
return { agent: live }
}
async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
const fenced = fencedLiveAgent(sessionId)
if (fenced !== undefined) return fenced
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
return { error: subagentOwnershipError(sessionId) }
}
if (live !== undefined) return { agent: live }
let resume = resumes.get(sessionId)
if (resume === undefined) {
resume = (async () => {
@@ -1117,6 +1138,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (error instanceof SubagentSessionOwnership) {
return { error: subagentOwnershipError(error.sessionId) }
}
// A concurrent publish can win the identity between the pre-resume
// re-check and `ctx.agents.resume` publication; the ID-collision
// rejection falls through here. Mirror ensureSession's `.catch` in
// full: classify a subagent-owned winner into the stable ownership
// error, and hand a clean plain-agent winner straight back.
const fenced = fencedLiveAgent(sessionId)
if (fenced !== undefined) return fenced
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
return { error: subagentOwnershipError(sessionId) }
}
// The internal details slot is contractually {}; the reason rides the message.
return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
}
@@ -1192,13 +1224,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
? undefined
: (await persistence.list()).find(header => header.id === sessionId)
if (persistence !== undefined && stored !== undefined) {
if (stored.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
}
const inspected = await persistence.inspect(sessionId)
// Ownership first: explicit-id adoption of a session-backed
// subagent must answer `agent-busy` regardless of the requested
// cwd (the api/commands.ts contract), not a cwd conflict.
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
throw new SubagentSessionOwnership(sessionId)
}
if (inspected.meta.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
}
return (await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions,
@@ -2266,9 +2301,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
commands: {
// Both methods address one session's agent (agentFor keeps its
// resume-on-miss: clients only send a sessionId for a published
// session, and resume restores an existing entity).
// Both methods address one session's agent. agentFor resumes on miss
// and fences every subagent-owned identity with `agent-busy`; the
// api/commands.ts module contract owns that fence's wording, so this
// comment only notes the routing shape: clients send a sessionId for a
// published session, and resume restores an existing entity.
async list(request) {
// Missing service = the deployment omitted dsh-commands from its
// composition, not an empty catalog: fail loud instead of serving [].

View File

@@ -338,4 +338,43 @@ describe('sessions.prompt synchronous rejection', () => {
}
}
})
it('classifies a raced cold-resume ID collision as agent-busy', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const sessionId = sid('race-resume')
const meta: SessionHeader = header('race-resume', 1000)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
locate: () => undefined,
} as never)
// The raced winner: a live parent-owned subagent publishes the identity
// while the generic cold resume is in flight, so the resume collides.
const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } })
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
ctx.agents.register(parent)
const childSession = ctx.sessions.create(sessionId, {
meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
})
const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent
vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
// The parent's `enter()` wins the identity between the pre-resume
// re-check and publication; the generic resume then collides.
ctx.agents.register(child)
throw new Error('session id already published')
})
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const models = await api.sessions.models(request({ sessionId }))
expect(models.result.ok).toBe(false)
if (!models.result.ok) {
expect(models.result.error).toMatchObject({
code: 'agent-busy',
details: { reason: 'use subagent delivery for this child session' },
})
}
})
})

View File

@@ -12,6 +12,7 @@
*/
import type { Context } from 'cordis'
import type { AgentSetupCommit } from '@deepseek-ai/dsh-agent'
import { errorChain } from '@deepseek-ai/dsh-llm'
import { SubagentError } from './error.ts'
@@ -47,17 +48,6 @@ interface TransactionState {
invalidated: boolean
}
/** Package-private setup transaction consumed by the continuation manager. */
export interface ActivationSetupTransaction {
/**
* Reject a batch invalidated by revocation before publication.
* @throws {SubagentError} code `ACTIVATION_SETUP_REVOKED` after revocation.
*/
assertIntact(): void
/** Promote this batch to resident installations. */
commit(): void
}
/** Re-read mutable removal state after a contribution may have revoked itself. */
function isRemoved(registration: Registration): boolean {
return registration.removed
@@ -95,9 +85,9 @@ export class SubagentActivationSetupRegistry {
/**
* Install every live contribution into one unpublished child context.
* @param childCtx - the child's unpublished scoped context.
* @returns the provisioning transaction.
* @returns the provisioning commit consumed at Agent publication.
*/
apply(childCtx: Context): ActivationSetupTransaction {
apply(childCtx: Context): AgentSetupCommit {
const state: TransactionState = { installations: [], invalidated: false }
try {
for (const registration of [...this.registrations]) {
@@ -135,15 +125,14 @@ export class SubagentActivationSetupRegistry {
}
childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()')
return {
assertIntact: () => {
if (!state.invalidated) return
throw new SubagentError(
'a continuable-subagent setup contribution was revoked while this child was being built; '
+ 'the child was not established',
'ACTIVATION_SETUP_REVOKED',
)
},
commit: () => {
if (state.invalidated) {
throw new SubagentError(
'a continuable-subagent setup contribution was revoked while this child was being built; '
+ 'the child was not established',
'ACTIVATION_SETUP_REVOKED',
)
}
for (const installation of state.installations) installation.transaction = undefined
},
}

View File

@@ -20,6 +20,7 @@ import type {
Agent,
AgentHandle,
AgentOptions,
AgentSetupCommit,
CreateAgentOptions,
} from '@deepseek-ai/dsh-agent'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
@@ -42,7 +43,6 @@ import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequ
import type { ActivationObserver } from './lifecycle.ts'
import { SubagentError } from './error.ts'
import type SubagentActivationSetupRegistry from './activation-setup-registry.ts'
import type { ActivationSetupTransaction } from './activation-setup-registry.ts'
/** Attribution for a model coordinator's follow-up to one of its children. */
export interface CoordinatorMessageSource {
@@ -800,10 +800,9 @@ export class SubagentContinuationManager {
// `AgentRegistry.enter()` is the authoritative collision boundary for an id
// some other owner holds — a duplicate would reject there with rollback.
inputs.signal.throwIfAborted()
let setupTransaction!: ActivationSetupTransaction
const setup = (childCtx: Context): void => {
const setup = (childCtx: Context): AgentSetupCommit => {
applyChildComposition(childCtx, inputs.composition)
setupTransaction = this.setupRegistry.apply(childCtx)
return this.setupRegistry.apply(childCtx)
}
const observer = this.host.observeActivation(provider, childId, parent)
const { create } = inputs
@@ -842,7 +841,6 @@ export class SubagentContinuationManager {
try {
inputs.signal.throwIfAborted()
this.assertAdmitting(parent)
setupTransaction.assertIntact()
this.acquireOwnership(parent, childId)
// Every accepted id leaves the inbox exactly once, through dequeue or
// discard. Clearing it there is what lets `stateOf()` distinguish a truly
@@ -860,8 +858,8 @@ export class SubagentContinuationManager {
for (const item of items) activation.accepted.delete(item.message.id)
this.wake(activation)
})
// Resident setup revokes live from here instead of invalidating creation.
setupTransaction.commit()
// Agent creation committed setup at its publication boundary;
// revocations from here on are immediate live revocation.
// Publish the start edge before any turn can run, so observers see this
// epoch before its first request.
observer.start(handle.agent)

View File

@@ -12,6 +12,11 @@
* omits `subagentDepth` — cold resume trusts the persisted header's
* `delegationDepth` as the monotone floor — and `outputSchema`, which belongs
* to one activation's result contract rather than durable child composition.
* Per-activation knobs such as `maxTokens` are omitted for the same reason as
* `outputSchema`: they budget one activation. Cold resume requires the exact
* live parent for authorization but reconstructs child options only from the
* durable descriptor, so it neither restores the prior budget nor inherits
* the parent's current one; the resumed route's defaults apply instead.
*
* @module @deepseek-ai/dsh-subagent/descriptor
*/

View File

@@ -19,8 +19,7 @@ describe('SubagentActivationSetupRegistry', () => {
const transaction = registry.apply(child.ctx)
expect(order).toEqual(['first', 'second'])
expect(() => { transaction.assertIntact() }).not.toThrow()
transaction.commit()
expect(() => { transaction.commit() }).not.toThrow()
expect(order).toEqual(['first', 'second'])
})
@@ -68,7 +67,7 @@ describe('SubagentActivationSetupRegistry', () => {
remove()
expect(disposals).toBe(1)
expect(() => { transaction.assertIntact() }).toThrow(/revoked while this child was being built/)
expect(() => { transaction.commit() }).toThrow(/revoked while this child was being built/)
})
it('catches a contribution revoked inside its own installer', () => {
@@ -82,7 +81,7 @@ describe('SubagentActivationSetupRegistry', () => {
const transaction = registry.apply(childContext().ctx)
expect(disposals).toBe(1)
expect(() => { transaction.assertIntact() }).toThrow(/revoked/)
expect(() => { transaction.commit() }).toThrow(/revoked/)
})
it('attempts every contribution-removal disposer before reporting failures', () => {

View File

@@ -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/subagent/tool-subagent-report/README.md
README.md: e15b8b5d5881fd7b6868995fec22048a605f4c7e
README.zh.md: 0c41bc9c1e5aa4d728789b064f2d00c8da8ca6c8
README.md: cd73154dfb9c8b37f4a811c3beedbe6a63207f58
README.zh.md: 4b31bed48ea0e50ec3a9d507548658defb94b8b8

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it. The package registers a continuable-child setup contribution instead of a global tool, so `report` exists only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package.
A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A missing, disposed, or closing parent fails the call with `direct parent is not live; report was not delivered`; the service performs no injection, parent cold resume, or offline mailbox write, so the durable child transcript remains the recovery source.
A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted).
`reportDelivery` selects parent scheduling for every accepted report. `quiet` (the default) uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. `wakeup` uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call.
@@ -58,7 +58,6 @@ Append-only; the report follows the parent's reusable request prefix. Waking del
## Known Limitations and Deferred Work
- **Setup revocation can follow lower-level Session publication** — the final revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, by which point that call has already published its Agent and Session. Revocation in this window rolls back the handle and prevents the subagent Activation start edge, but may leave a persisted Session. Closing this gap requires a future Agent-creation setup transaction seam before lower-level publication.
- **A parent whose host-owned disposal already started can still accept** — `AgentHandle.dispose()` cancels, awaits quiescence, and only then unwinds the scope and leaves the registry; it exposes no signal for "disposal started." A report accepted in that window is appended to the parent's transcript, but that parent will not act on it in this process. A continuation-manager-owned parent rejects forest teardown through the manager's admission boundary.
- **Acceptance is weaker than durable delivery** — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure after one side recorded acceptance leaves the outcome ambiguous, and an external retry may duplicate the report.
- **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary.

View File

@@ -4,7 +4,7 @@
可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent智能体。本包package注册的是可继续子级设置贡献而不是全局工具因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域功能;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。
子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation也不会阻止父级后续消息轮次结束也绝不会自动上报。该工具不接受接收方参数`exec.agent` 是发送方准确的实时 Agent也是权限凭据服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级不存在、已 dispose资源释放或正在关闭时本次调用失败并返回 `direct parent is not live; report was not delivered`;服务不会执行注入、父级冷恢复或离线 mailbox 写入,因此持久化子级 transcript文本记录仍是恢复真源。
子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation也不会阻止父级后续消息轮次结束也绝不会自动上报。该工具不接受接收方参数`exec.agent` 是发送方准确的实时 Agent也是权限凭据服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`已开始宿主 dispose 但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入持久化子级 transcript文本记录仍是恢复真源,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)
`reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,准确创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering中途引导。这是部署调度策略因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。
@@ -58,7 +58,6 @@
## 已知限制与暂缓事项
- **setup 撤销可能发生在底层 Session 发布之后**:最终撤销检查发生在 `ctx.agents.create()``ctx.agents.resume()` 返回之后,此时该调用已发布其 Agent 和 Session。在这个窗口内撤销会回滚 handle并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。要弥合这个缺口需要未来在底层发布之前提供 Agent 创建 setup 事务 seam。
- **父级可能在宿主启动 dispose 后继续接受报告**`AgentHandle.dispose()` 会先取消并等待完全停稳然后才撤销作用域并离开注册表它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript但该父级不会在本进程中处理它。对于由延续管理器拥有的父级管理器的准入边界会在整棵子树拆卸期间拒绝该上报。
- **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议也不保证恰好一次。任一侧记录接受后若进程失败结果都不明确外部重试可能产生重复上报。
- **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。

View File

@@ -88,7 +88,10 @@ export function installReportTool(
* @param config - deployment scheduling policy.
*/
export function apply(ctx: Context, config: Config = {}): void {
const { reportDelivery = 'quiet' } = Config(config)
// Config() applies the schema default ('quiet') at runtime; the schemastery
// return type keeps the input's optional shape, so assert the resolved
// shape here — no runtime fallback exists or is wanted.
const { reportDelivery } = Config(config) as { reportDelivery: SubagentReportDelivery }
ctx.subagents.registerContinuableSetup(childCtx =>
installReportTool(childCtx, ctx, reportDelivery))
}

View File

@@ -327,6 +327,15 @@ describe('dsh-tool-subagent-report', () => {
return dispose
})
// No session may be announced for the rejected child: the setup
// validation must reject inside the creation callback, before the factory
// publishes — a post-publication rejection would persist a resumable
// ghost that `list_agents` surfaces and `send_message` can resurrect.
// The parent was created inside setup(), so any later announcement is the
// rejected child's.
const announced: SessionId[] = []
const listener = (session: { id: SessionId }): void => { announced.push(session.id) }
const removeListener = ctx.on('session/created', listener)
await expect(ctx.subagents.startContinuable({
provider: 'spawn',
label: 'racing child',
@@ -336,9 +345,56 @@ describe('dsh-tool-subagent-report', () => {
},
signal: testSignal,
})).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
removeListener()
expect(announced).toEqual([])
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
})
it('rolls back materialization when setup revocation lands before publication', async () => {
const { ctx, parent } = await setup({ load: false })
const self: { revoke?: () => void } = {}
let installed = false
self.revoke = ctx.subagents.registerContinuableSetup(() => {
installed = true
queueMicrotask(() => { self.revoke?.() })
return () => { installed = false }
})
const announced: SessionId[] = []
const removeListener = ctx.on('session/created', (session) => { announced.push(session.id) })
await expect(ctx.subagents.startContinuable({
provider: 'spawn',
label: 'revoked child',
request: {
prompt: [{ type: 'text', text: 'revoked child' }],
parent,
},
signal: testSignal,
})).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
removeListener()
expect(installed).toBe(false)
expect(announced).toEqual([])
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
expect(ctx.sessions.list()).toEqual([parent.session])
})
it('accepts a report into a host-disposing but still-registered parent', async () => {
const { ctx } = await setup()
const parentHandle = await ctx.agents.create({
sessionId: SessionId('disposing-parent'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const { child } = await startChild(ctx, parentHandle.agent)
// Host-owned disposal starts asynchronously; the parent stays registered
// until quiescence, and registry presence — not disposal state — is the
// acceptance gate (pins the README contract).
const disposing = parentHandle.dispose()
const accepted = await callReport(ctx, child, 'during-close')
expect(accepted.isError).toBe(false)
await disposing
expect((await callReport(ctx, child, 'after-close')).isError).toBe(true)
})
it('keeps the namespace plugin shape and validates its default', () => {
expect('default' in tool).toBe(false)
expect(tool.name).toBe('tool-subagent-report')

View File

@@ -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/support/llm-replay/README.md
README.md: 0deb6e76b29d40483b754ac01c98ee0e01bfcbe8
README.zh.md: 7720e2d1bc6eb7bc5c89d5c1708767a54a7b0080
README.md: 85aa56705929e7630e4cfb6c2a3c9cbbd0d843a6
README.zh.md: 751f75dea197ffb112cfa703e3a5dbfaffb8c0b2

View File

@@ -12,6 +12,8 @@ The fixture IS the persisted session log (`<scenario>/session.jsonl`). Its `assi
Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`<scenario>/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update.
A scripted string may embed `{{fromRequest:<regex>}}` to fill a value no static sidecar can know — for example a randomly minted goal id the model must echo back into `update_goal`. At stream time every placeholder resolves against the live request: the corpus is every string leaf of the request messages joined by newlines, the pattern's LAST corpus match wins, and its first capture group (or the whole match without one) substitutes in place. A pattern that matches nothing, an invalid pattern, and an unterminated placeholder each fail loud. The last two braces of a consecutive `}` run terminate the placeholder, so a pattern may end with a brace quantifier (`[0-9a-f]{4}`) but cannot contain `}}` followed by further pattern content. Resolution applies to every scripted entry, including ones derived from the recorded JSONL — a recorded fixture whose text legitimately contains the literal marker must be expressed through a sidecar without it.
## Nested agents: per-session keying
A scenario where a parent agent delegates to in-process subagents records more than one log: the parent (`session.jsonl`) plus one per child (`session.1.jsonl`, …). Each agent runs as its own `Session` on the same context, so replay must serve each one its own script.
@@ -55,7 +57,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
- `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars.
- `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order.
- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing).
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
- Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`.
## Plugin export shape

View File

@@ -12,6 +12,8 @@ fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `as
有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`<scenario>/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。
脚本字符串可以内嵌 `{{fromRequest:<regex>}}`,用来填入静态伴随文件不可能预知的值——例如模型必须原样回填到 `update_goal` 的随机生成 goal id。回放时每个占位符针对实时请求解析语料是请求消息的所有字符串叶子按换行拼接的结果取该模式在语料中的最后一次匹配用其第一个捕获组无捕获组时用整个匹配原位替换。模式匹配不到内容、模式非法、占位符未闭合都会明确报错。连续右花括号串的最后两个花括号才是占位符结束符因此模式可以以花括号量词收尾`[0-9a-f]{4}`),但不能在 `}}` 之后还有后续模式内容。解析作用于所有脚本条目,包括从已记录 JSONL 派生的条目——若录制文本本身合法地含有该字面量标记,需改用不含标记的伴随文件表达。
## 嵌套 agent每会话键控
父 agent 委托给进程内 subagent子 agent的场景会记录多个日志父会话使用 `session.jsonl`,每个子会话各使用一个日志(`session.1.jsonl` 等)。每个 agent 都在同一上下文中作为独立的 `Session` 运行,因此回放必须为每个 agent 提供各自的脚本。
@@ -55,7 +57,7 @@ fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `as
- `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR热模块替换安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。
- `loadSessionScripts(config)`:解析场景的有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。
- `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生fixture 缺失时明确报错)。
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)`:将已记录会话日志转换为脚本读取其 header `id`/`createdAt` 的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志转换为脚本读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。
- 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`
## 插件导出形态

View File

@@ -241,6 +241,96 @@ const REPLAY_CHUNK_TYPES = new Set<StreamChunk['type']>([
'finish',
])
const FROM_REQUEST_OPEN = '{{fromRequest:'
const FROM_REQUEST_CLOSE = '}}'
/** Collect every string leaf of one JSON-shaped value, in traversal order. */
function collectStrings(value: unknown, out: string[]): void {
if (typeof value === 'string') {
out.push(value)
return
}
if (Array.isArray(value)) {
for (const item of value) collectStrings(item, out)
return
}
if (value !== null && typeof value === 'object') {
for (const item of Object.values(value)) collectStrings(item, out)
}
}
/** Resolve one placeholder pattern against the request corpus; the LAST match wins. */
function resolveFromRequest(pattern: string, corpus: string): string {
let regex: RegExp
try {
regex = new RegExp(pattern, 'g')
} catch (error) {
// RegExp construction only throws SyntaxError; String() carries its message.
throw new Error(`llm-replay: fromRequest has an invalid pattern ${JSON.stringify(pattern)}: ${String(error)}`)
}
let last: RegExpExecArray | undefined
for (const match of corpus.matchAll(regex)) last = match
if (last === undefined) {
throw new Error(`llm-replay: fromRequest pattern ${JSON.stringify(pattern)} matched nothing in the request`)
}
return last[1] ?? last[0]
}
/** Replace every `{{fromRequest:<pattern>}}` occurrence in one scripted string. */
function substituteString(text: string, corpus: string): string {
let result = ''
let cursor = 0
while (true) {
const open = text.indexOf(FROM_REQUEST_OPEN, cursor)
if (open === -1) return result + text.slice(cursor)
let close = text.indexOf(FROM_REQUEST_CLOSE, open + FROM_REQUEST_OPEN.length)
if (close === -1) {
throw new Error(`llm-replay: fromRequest placeholder is unterminated in ${JSON.stringify(text)}`)
}
// The last two braces of a consecutive `}` run terminate the placeholder,
// so a pattern may end with a brace quantifier like `[0-9a-f]{4}`.
while (text[close + FROM_REQUEST_CLOSE.length] === '}') close += 1
const pattern = text.slice(open + FROM_REQUEST_OPEN.length, close)
result += text.slice(cursor, open) + resolveFromRequest(pattern, corpus)
cursor = close + FROM_REQUEST_CLOSE.length
}
}
/** Deep-copy one JSON-shaped value with scripted placeholders resolved. */
function substituteValue(value: unknown, corpus: string): unknown {
if (typeof value === 'string') {
return value.includes(FROM_REQUEST_OPEN) ? substituteString(value, corpus) : value
}
if (Array.isArray(value)) return value.map(item => substituteValue(item, corpus))
if (value !== null && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteValue(item, corpus)]))
}
return value
}
/**
* Resolve every `{{fromRequest:<regex>}}` placeholder in one scripted entry
* against the live request. The corpus is every string leaf of the request
* messages joined by newlines; the pattern's LAST corpus match wins and its
* first capture group (or, without one, the whole match) substitutes in place.
* Scenario sidecars use this to script arguments no static file can know,
* such as a randomly minted goal id the model must echo back. A pattern that
* matches nothing, an invalid pattern, and an unterminated placeholder each
* fail loud. The last two braces of a consecutive `}` run terminate the
* placeholder, so a pattern may end with a brace quantifier but cannot
* contain `}}` followed by further pattern content. Derived entries pass
* through the same resolution as sidecar entries.
* @param entry - the scripted entry about to replay.
* @param messages - the live request messages searched by the placeholders.
* @returns the entry itself when no placeholder appears, else a resolved deep copy.
*/
export function resolveScriptedEntry(entry: ReplayEntry, messages: GenerateOptions['messages']): ReplayEntry {
if (!JSON.stringify(entry).includes(FROM_REQUEST_OPEN)) return entry
const leaves: string[] = []
collectStrings(messages, leaves)
return substituteValue(entry, leaves.join('\n')) as ReplayEntry
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -583,7 +673,7 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): ReplayHand
+ `but its script has only ${boundState.entries.length}; re-record the scenario`,
)
}
yield* replayEntry(entry, options.signal, paceMs)
yield* replayEntry(resolveScriptedEntry(entry, options.messages), options.signal, paceMs)
})()
}
const providers = config.providers ?? []

View File

@@ -4,7 +4,7 @@ import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import LlmService, { GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm'
import {
type ReplayEntry,
type SessionScript,
@@ -17,6 +17,7 @@ import {
name,
parseSessionHeader,
parseSessionLog,
resolveScriptedEntry,
} from '../src/index.ts'
/**
@@ -310,6 +311,80 @@ describe('installLlmReplay (through the real LlmService)', () => {
expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS)
})
describe('{{fromRequest:...}} substitution', () => {
const requestMessages = [createUserMessage({
content: [{ type: 'text' as const, text: 'stale {"goal":{"id":"goal-old"}} then {"goal":{"id":"goal-42ab"}}' }],
source: { kind: 'user' as const },
})]
function scriptedCall(argumentsDelta: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'update_goal', argumentsDelta },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'update_goal', arguments: argumentsDelta } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
async function streamScripted(argumentsDelta: string): Promise<StreamChunk[]> {
writeLog(TEXT_CHUNKS)
const overrideFile = join(dir, 'replay.override.json')
writeFileSync(overrideFile, JSON.stringify([{ kind: 'chunks', chunks: scriptedCall(argumentsDelta) }]), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
installLlmReplay(ctx, { file, overrideFile })
return drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: requestMessages }))
}
it('resolves the capture group from the LAST request match in every scripted string field', async () => {
const streamed = await streamScripted('{"goal_id":"{{fromRequest:"id":"(goal-[^"]+)"}}","revision":1}')
const delta = streamed.find(chunk => chunk.type === 'tool-call-delta')
expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab","revision":1}' })
const end = streamed.find(chunk => chunk.type === 'block-end')
expect(end).toMatchObject({ block: { arguments: '{"goal_id":"goal-42ab","revision":1}' } })
})
it('substitutes the whole match when the pattern has no capture group', async () => {
const streamed = await streamScripted('{"goal_id":"{{fromRequest:goal-[0-9a-z]+}}"}')
const delta = streamed.find(chunk => chunk.type === 'tool-call-delta')
expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' })
})
it('keeps a trailing brace quantifier inside the pattern (terminator is the run tail)', async () => {
const streamed = await streamScripted('{"goal_id":"{{fromRequest:goal-[0-9a-z]{4}}}"}')
const delta = streamed.find(chunk => chunk.type === 'tool-call-delta')
expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' })
})
it('fails loud when a placeholder matches nothing in the request', async () => {
await expect(streamScripted('{"goal_id":"{{fromRequest:task-[0-9]+}}"}'))
.rejects.toThrow(/fromRequest.*matched nothing/)
})
it('fails loud on an invalid placeholder pattern', async () => {
await expect(streamScripted('{"goal_id":"{{fromRequest:(goal-}}"}'))
.rejects.toThrow(/fromRequest.*invalid pattern/)
})
it('fails loud on an unterminated placeholder', () => {
const entry: ReplayEntry = { kind: 'chunks', chunks: scriptedCall('{"goal_id":"{{fromRequest:goal-1"}') }
expect(() => resolveScriptedEntry(entry, requestMessages)).toThrow(/fromRequest placeholder is unterminated/)
})
it('returns the exact same entry when no placeholder appears', () => {
const entry: ReplayEntry = { kind: 'chunks', chunks: TEXT_CHUNKS }
expect(resolveScriptedEntry(entry, requestMessages)).toBe(entry)
})
it('skips non-string request leaves when building the corpus', () => {
const messages = requestMessages.map(message => ({ ...message, seq: 7 })) as unknown as GenerateOptions['messages']
const entry: ReplayEntry = { kind: 'chunks', chunks: scriptedCall('{"goal_id":"{{fromRequest:goal-42[a-z]+}}"}') }
const resolved = resolveScriptedEntry(entry, messages)
if (resolved.kind !== 'chunks') throw new Error('expected chunks entry')
expect(resolved.chunks[1]).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' })
})
})
it('registers a replay-only provider catalog when configured', async () => {
writeLog(TEXT_CHUNKS)
const ctx = new Context()