Merge origin/master into worktree/web-plugin-config
Three seams: the tsconfig path map gained a mapping on each side and keeps both; the event-producer matrix is generated, so it was regenerated rather than hand-merged row by row.
This commit is contained in:
@@ -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: b16ba8186254468a335f6d55ce550de86a3436ef
|
||||
README.md: 13f79b9e70bfb658c459240c97acf39025df2ac0
|
||||
README.zh.md: ad8fc9478eb45620cee303eb7295b6b3fe8b0ed3
|
||||
|
||||
@@ -12,7 +12,7 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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 set. 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.
|
||||
|
||||
Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain → unwind scope → detach agent → detach session; the id becomes reusable after private scope cleanup. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, and per-step assembly goes through `assembleContextFor(agent)`.
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
* `agent/session-start`, `agent/turn-stopping`, and the
|
||||
* `tools/pre-execute` / `tools/post-execute`
|
||||
* split with `additionalContexts` buffering. These verify the canonical event
|
||||
* surface a hook bridge (or a native plugin) programs against, WITHOUT any
|
||||
* API a hook bridge (or a native plugin) programs against, WITHOUT any
|
||||
* external protocol — a native plugin uses the typed decisions directly.
|
||||
*/
|
||||
|
||||
|
||||
@@ -605,7 +605,7 @@ describe('agent scope lifecycle', () => {
|
||||
agentCtx.systemPrompt.section({
|
||||
name: 'dependency-origin-section',
|
||||
order: 1,
|
||||
text: 'factory dependency surface',
|
||||
text: 'factory dependency API',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
* The tool registry itself stays on the host plane — the agent loop's
|
||||
* scheduler, the API proxy's presenters, and every tool plugin are all its
|
||||
* consumers, so it cannot move into a preset. What a preset CAN own is the
|
||||
* presentation: `ctx.tools.presentAs()` declares it for the mounting agent
|
||||
* alone, so a Code Mode agent runs beside native ones in one process.
|
||||
* presentation: `ctx.tools.presentAs()` declares it for the mounting SCOPE,
|
||||
* which is the preset's standing mount, so the declaration covers every agent
|
||||
* joined to that preset and a Code Mode preset runs beside native ones in one
|
||||
* process. One row per composition, not one per session.
|
||||
*
|
||||
* A code mode needs a TypeScript code runtime, which is a host-plane service
|
||||
* ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)).
|
||||
@@ -50,8 +52,8 @@ export const Config: z<Config> = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Declare this agent's tool presentation.
|
||||
* @param ctx - the mounting agent's scope context.
|
||||
* Declare the tool presentation for every agent this composition covers.
|
||||
* @param ctx - the mounting composition's scope context (a preset's standing scope).
|
||||
* @param config - the selected presentation.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
|
||||
@@ -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: 0fb65b94a3b311aa9f0df09d39dd937cba4cc7b4
|
||||
README.zh.md: 44f67483343a98c280317793ece544bd0b984596
|
||||
README.md: f6b698e93b254c97786155e7d2c7e81f07c0d981
|
||||
README.zh.md: 8b108ca93b11ce5e4af57f9d9c823a4b10eac5ab
|
||||
|
||||
@@ -58,6 +58,8 @@ Inbox live notifications are deliberately per-message and minimal: `agent/inbox/
|
||||
|
||||
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
|
||||
|
||||
`foldConsumedWork(events)` reads that feed back for the one question the turn sequence cannot answer alone: what became of the work a log consumed. It returns the latest `turn/end` that accounts for consumed work — a turn that entered a model step, or one that claimed inbox input and then failed, was stopped, or was rejected before reaching one — plus whether accepted work was later cancelled out of the inbox unrun. Both facts come from the log, so a cancellation reads the same whichever owner issued it. A no-step turn that took nothing, or emptied its claim and completed, describes no work and is skipped; a `blocked` end over claimed input is an account, because rejection discarded that input.
|
||||
|
||||
### Agent interface (`types.ts`)
|
||||
|
||||
The handle every plugin programs against:
|
||||
@@ -114,6 +116,6 @@ Prefix-stable while an agent's scoped registrations are unchanged. Setup or relo
|
||||
- **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work.
|
||||
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
|
||||
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
|
||||
- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
|
||||
- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop API Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-api.md)).
|
||||
- **Each additional `UserMessage` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source, so the message cannot name several producers.
|
||||
- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`).
|
||||
|
||||
@@ -58,6 +58,8 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte
|
||||
|
||||
轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。
|
||||
|
||||
`foldConsumedWork(events)` 把这条事件流读回来,回答仅凭轮次序列无法回答的那个问题:一份日志消费掉的工作最终怎样了。它返回能够为已消费工作作出交代的最新 `turn/end`——即进入过模型 step 的轮次,或者认领了 inbox 输入、但在进入 step 之前失败、被停下或被拒绝的轮次——并额外给出「已接受的工作此后是否被从 inbox 中取消且从未运行」。两项事实都来自日志,因此无论由哪个所有者发起取消,读出来都一样。没有取走任何输入、或认领批次被改写清空后正常结束的无 step 轮次不描述工作,会被跳过;认领过输入、以 `blocked` 结束的轮次则是一份交代,因为拒绝把这些输入一并丢弃了。
|
||||
|
||||
### Agent 接口(`types.ts`)
|
||||
|
||||
每个插件面向的 handle:
|
||||
@@ -114,6 +116,6 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte
|
||||
- **环境身份可能比存活状态更久**:消费方在生命周期敏感工作前,仍要检查 `agent.status`、取消状态和所属能力约定。
|
||||
- **委派以外的 agent 间通道**:共享状态、流式子输出和后台/轮询语义仍在当前同步 `ctx.subagents` seam 之外。
|
||||
- **`agent/session-start` 不能为启动设置门禁**:它仍是同步且不可 veto 的通知;必须在发布前完成的异步组合属于工厂的 `setup(agentCtx)` 事务。
|
||||
- **`cancel()` 默认清空 inbox**:它会中止正在处理的轮次以及排队和 steering 工作;`cancel(cause, { keepInbox: true })` 只中止轮次并保留待处理项。仍不存在只中止步骤、同时让正在处理的轮次继续运行的操作([关于停止操作接口的 Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md))。
|
||||
- **`cancel()` 默认清空 inbox**:它会中止正在处理的轮次以及排队和 steering 工作;`cancel(cause, { keepInbox: true })` 只中止轮次并保留待处理项。仍不存在只中止步骤、同时让正在处理的轮次继续运行的操作([关于停止操作接口的 Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-api.md))。
|
||||
- **每条附加 `UserMessage` 恰好携带一个 `MessageSource`**:多个插件合并到一次工具调用上的贡献会归入同一来源,因此该消息无法列出多个生产者。
|
||||
- **`SessionStartSource` 预留 `'clear'`/`'compact'`,但还没有发出方**:在驱动子系统落地前,只会出现 `'startup'`/`'resume'`(`TODO(compaction)`)。
|
||||
|
||||
108
packages/core/agent/src/consumed-work.ts
Normal file
108
packages/core/agent/src/consumed-work.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* How one agent log accounts for the work it consumed.
|
||||
*
|
||||
* The turn and step vocabulary alone cannot answer this. A turn that stops
|
||||
* before its first step leaves a `turn/end` shaped exactly like the balanced
|
||||
* no-op turns a rejection or an empty claim produces, so reading turns in
|
||||
* isolation either credits cut-short work as finished or convicts every no-op.
|
||||
* The missing fact is the inbox's own record: {@link Inbox} logs each mutation
|
||||
* with `removedCount` and marks a cancellation `outcome: 'canceled'`, which
|
||||
* separates a turn claiming its input from work being dropped unrun.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent/consumed-work
|
||||
*/
|
||||
|
||||
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** How one agent log accounts for the work it consumed. */
|
||||
export interface ConsumedWork {
|
||||
/**
|
||||
* The latest closed turn that accounts for consumed work: one that entered a
|
||||
* model step, or one that claimed inbox input and then failed, was stopped,
|
||||
* or was rejected. Absent when no turn closed over any work.
|
||||
*/
|
||||
readonly end?: SessionEvent<'turn/end'>
|
||||
/**
|
||||
* Whether accepted work was cancelled out of the inbox, unrun, after that
|
||||
* turn. This is the only account of input a cancellation took before any turn
|
||||
* could open over it — no `turn/end` describes it.
|
||||
*/
|
||||
readonly droppedUnrun: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a turn that consumed input but never reached a step ends in a way
|
||||
* that accounts for that input. Only a `completed` end does not: it had
|
||||
* nothing left to run once its claim was rewritten away. A `blocked` end is
|
||||
* that input's ending too — the pre-step rejection that produced it discarded
|
||||
* the claimed messages, so the work it took will never run.
|
||||
* @param reason - the turn's recorded ending.
|
||||
* @returns whether the ending accounts for the input the turn took.
|
||||
*/
|
||||
function accountsForClaim(reason: TurnEndReason): boolean {
|
||||
switch (reason.kind) {
|
||||
case 'completed':
|
||||
return false
|
||||
case 'blocked':
|
||||
case 'aborted':
|
||||
case 'interrupted':
|
||||
case 'error':
|
||||
return true
|
||||
/* v8 ignore next 4 -- unreachable: the one unnamed built-in, `max-tokens`, requires a step,
|
||||
* so its turn short-circuits as stepped before this call, and `TurnEndReasonMap` is
|
||||
* merge-extensible, so a backend-added variant cannot be listed; an unnameable ending over
|
||||
* consumed input must not read as success. */
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one agent log, or an owned suffix of one, into its account of consumed
|
||||
* work. Single pass, and every input is the log itself: no caller has to sample
|
||||
* live state before cancelling, so a cancellation issued by anyone — the owner's
|
||||
* teardown, an ancestor's interrupt, an unloading plugin — reads the same.
|
||||
* @param events - the log, or an owned suffix, to fold.
|
||||
* @returns the accounting turn when one closed, and whether work was dropped unrun after it.
|
||||
*/
|
||||
export function foldConsumedWork(events: readonly SessionEvent[]): ConsumedWork {
|
||||
const stepped = new Set<number>()
|
||||
const claimed = new Set<number>()
|
||||
let open: number | undefined
|
||||
let end: SessionEvent<'turn/end'> | undefined
|
||||
let droppedUnrun = false
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
open = event.data.turn
|
||||
break
|
||||
case 'step/start':
|
||||
stepped.add(event.data.turn)
|
||||
break
|
||||
case 'agent/inbox/spliced': {
|
||||
const { removedCount, outcome, inserted } = event.data
|
||||
if (removedCount === undefined) break
|
||||
// A replacement keeps the work pending under a new identity, so only a
|
||||
// cancellation that leaves nothing behind drops it.
|
||||
if (outcome === 'canceled') droppedUnrun ||= inserted.length === 0
|
||||
// Claims are the loop's own step-boundary reads, always inside a turn.
|
||||
else if (open !== undefined) claimed.add(open)
|
||||
break
|
||||
}
|
||||
case 'turn/end': {
|
||||
const { turn, reason } = event.data
|
||||
open = undefined
|
||||
if (stepped.delete(turn) || (claimed.delete(turn) && accountsForClaim(reason))) {
|
||||
end = event
|
||||
// Anything dropped before this turn closed is what its own ending
|
||||
// reports; only a later drop is still unaccounted for.
|
||||
droppedUnrun = false
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return { ...end === undefined ? {} : { end }, droppedUnrun }
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { Agent, AgentOptions } from './runtime-types.ts'
|
||||
export * from './runtime-types.ts'
|
||||
export * from './types.ts'
|
||||
export * from './inbox.ts'
|
||||
export * from './consumed-work.ts'
|
||||
export * from './model-selection.ts'
|
||||
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
|
||||
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
|
||||
@@ -159,7 +160,7 @@ export interface ResumeAgentOptions {
|
||||
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: among consumers,
|
||||
* only the holder can tear this agent down. The registered factory provider is
|
||||
* also a structural owner because the scoped agent depends on that provider's
|
||||
* service surface; provider unload stops and drains every live handle it made.
|
||||
* service API; provider unload stops and drains every live handle it made.
|
||||
* `dispose()` stops the loop, awaits its exit, unregisters the agent, removes
|
||||
* its session from the store, and finally unwinds its scoped world.
|
||||
*
|
||||
|
||||
160
packages/core/agent/tests/consumed-work.spec.ts
Normal file
160
packages/core/agent/tests/consumed-work.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { foldConsumedWork } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
/** One pending message, as the inbox records it. */
|
||||
function message(text: string) {
|
||||
return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
/** Log an accepted message the way `Inbox.append()` does. */
|
||||
function accept(session: Session, text: string): void {
|
||||
session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message(text)] })
|
||||
}
|
||||
|
||||
/** Log the step-boundary read of one pending message, as `Inbox.claim()` does. */
|
||||
function claim(session: Session): void {
|
||||
session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] })
|
||||
}
|
||||
|
||||
/** Log a cancellation of one pending message, as `Inbox.clear()` does. */
|
||||
function cancelPending(session: Session): void {
|
||||
session.append('agent/inbox/spliced', {
|
||||
target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
|
||||
})
|
||||
}
|
||||
|
||||
/** Run one whole turn that reached a model step. */
|
||||
function steppedTurn(session: Session, turn: number, reason: TurnEndReason): void {
|
||||
session.append('turn/start', { turn })
|
||||
claim(session)
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason })
|
||||
}
|
||||
|
||||
describe('foldConsumedWork', () => {
|
||||
it('reports nothing for a log that consumed no work', () => {
|
||||
const session = Session.create(SessionId('empty'))
|
||||
accept(session, 'queued')
|
||||
|
||||
expect(foldConsumedWork(session.events)).toEqual({ droppedUnrun: false })
|
||||
})
|
||||
|
||||
it('reports the latest turn that entered a model step', () => {
|
||||
const session = Session.create(SessionId('stepped'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
steppedTurn(session, 2, { kind: 'max-tokens' })
|
||||
|
||||
expect(foldConsumedWork(session.events).end?.data)
|
||||
.toEqual({ turn: 2, reason: { kind: 'max-tokens' } })
|
||||
})
|
||||
|
||||
it('reports a turn that claimed its input and then failed before any step', () => {
|
||||
const session = Session.create(SessionId('failed-claim'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
// The step boundary runs the durability checkpoint and prompt assembly, so a
|
||||
// turn can take its input and then fail without entering a step.
|
||||
session.append('turn/start', { turn: 2 })
|
||||
claim(session)
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'error', error: { message: 'ENOSPC', code: 'UNKNOWN' } } })
|
||||
|
||||
expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
|
||||
})
|
||||
|
||||
it('reports a turn that claimed its input and was then stopped before any step', () => {
|
||||
const session = Session.create(SessionId('stopped-claim'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
claim(session)
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } })
|
||||
|
||||
expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
|
||||
})
|
||||
|
||||
it('ignores a turn stopped, failed, or rejected without taking any input', () => {
|
||||
const session = Session.create(SessionId('no-claim'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'parent' } } })
|
||||
session.append('turn/start', { turn: 3 })
|
||||
session.append('turn/end', { turn: 3, reason: { kind: 'error', error: { message: 'x', code: 'UNKNOWN' } } })
|
||||
session.append('turn/start', { turn: 4 })
|
||||
session.append('turn/end', { turn: 4, reason: { kind: 'blocked' } })
|
||||
|
||||
// None of these turns describes work: they opened, found nothing of their own, and closed.
|
||||
expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
|
||||
})
|
||||
|
||||
it('reports a turn whose claimed input a pre-step rejection discarded', () => {
|
||||
const session = Session.create(SessionId('rejected-claim'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
claim(session)
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'blocked' } })
|
||||
|
||||
// Rejection does not retain the claimed messages, so the `blocked` end is
|
||||
// the only account of input that will never run.
|
||||
expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
|
||||
})
|
||||
|
||||
it('ignores a claim its own turn emptied', () => {
|
||||
const session = Session.create(SessionId('emptied-claim'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
claim(session)
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
|
||||
// An emptied claim ran nothing and dropped nothing: a listener rewrote the
|
||||
// batch away, which is not this log's account of the work.
|
||||
expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
|
||||
})
|
||||
|
||||
it('credits a claim with no open turn to no turn at all', () => {
|
||||
const session = Session.create(SessionId('mid-turn-suffix'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
// An owned suffix can begin inside a turn whose start it does not contain,
|
||||
// so a claim may appear with no turn to attribute it to.
|
||||
claim(session)
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } })
|
||||
|
||||
expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
|
||||
})
|
||||
|
||||
it('reports work cancelled out of the inbox after the last accounting turn', () => {
|
||||
const session = Session.create(SessionId('dropped'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
accept(session, 'never runs')
|
||||
cancelPending(session)
|
||||
|
||||
// No turn opened over it, so only the cancellation says the work was cut short.
|
||||
expect(foldConsumedWork(session.events)).toEqual({
|
||||
end: session.events.find(event => event.type === 'turn/end'),
|
||||
droppedUnrun: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a replacement pending rather than counting it as dropped', () => {
|
||||
const session = Session.create(SessionId('replaced'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
session.append('agent/inbox/spliced', {
|
||||
target: 'next-turn', start: 0, removedCount: 1, inserted: [message('rewritten')], outcome: 'canceled',
|
||||
})
|
||||
|
||||
expect(foldConsumedWork(session.events).droppedUnrun).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a later accounting turn absorb an earlier drop', () => {
|
||||
const session = Session.create(SessionId('absorbed'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
cancelPending(session)
|
||||
steppedTurn(session, 2, { kind: 'completed' })
|
||||
|
||||
expect(foldConsumedWork(session.events)).toEqual({
|
||||
end: session.events.findLast(event => event.type === 'turn/end'),
|
||||
droppedUnrun: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Negative-path tests for the export-surface JSDoc gate (`scripts/verify-export-jsdoc.ts`).
|
||||
* Negative-path tests for the exported-API JSDoc gate (`scripts/verify-export-jsdoc.ts`).
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
@@ -46,7 +46,7 @@ export function hiddenFn(value: string): string { return value }
|
||||
expect(violations.every(violation => violation.includes('publicFn'))).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a fully documented surface', () => {
|
||||
it('accepts a fully documented API', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/**
|
||||
* Add one to a count.
|
||||
@@ -106,7 +106,7 @@ export const halve = (n: number): number => n / 2
|
||||
'/**\n * Docs.\n * @param ghost - not real.\n */\nexport function f({ a }: { a: number }): void {}\n',
|
||||
))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/parameter '\{ a \}' is a binding pattern; the export surface needs simple identifier parameters/),
|
||||
expect.stringMatching(/parameter '\{ a \}' is a binding pattern; the exported API needs simple identifier parameters/),
|
||||
expect.stringMatching(/@param ghost does not match any parameter \(stale tag\?\)/),
|
||||
])
|
||||
})
|
||||
@@ -166,9 +166,9 @@ describe('verify-export-jsdoc export forms', () => {
|
||||
))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it('does not treat a never-exported sibling declarator as surface', () => {
|
||||
it('does not treat a never-exported sibling declarator as API', () => {
|
||||
// `export { publicValue }` resolves to the whole variable statement; only
|
||||
// the named declarator is surface — the gate must not demand JSDoc for
|
||||
// the named declarator is exported — the gate must not demand JSDoc for
|
||||
// the private sibling sharing the statement.
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** The public knob. */\nconst publicValue = 1, privateHelper = 2\nexport { publicValue }\nvoid privateHelper\n',
|
||||
@@ -177,7 +177,7 @@ describe('verify-export-jsdoc export forms', () => {
|
||||
|
||||
it('unions declarators across multiple export lists over one statement', () => {
|
||||
// Two lists each name one declarator of the same undocumented statement:
|
||||
// both are surface (deduplicating on first resolution would drop `b`),
|
||||
// both are exported (deduplicating on first resolution would drop `b`),
|
||||
// while the never-exported `c` stays out.
|
||||
const violations = collectExportJsdocViolations(make(
|
||||
'const a = 1, b = 2, c = 3\nexport { a }\nexport { b }\nvoid c\n',
|
||||
@@ -344,7 +344,7 @@ describe('verify-export-jsdoc fail-closed forms', () => {
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('treats an inline function-type annotation as the surface signature', () => {
|
||||
it('treats an inline function-type annotation as the API signature', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** Maps a number. */\nexport declare const f: (x: number) => number\n',
|
||||
))).toEqual([
|
||||
@@ -402,7 +402,7 @@ describe('verify-export-jsdoc fail-closed forms', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a single-call-signature type literal as the surface signature', () => {
|
||||
it('treats a single-call-signature type literal as the API signature', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** Maps. */\nexport declare const f: { (x: number): number }\n',
|
||||
))).toEqual([
|
||||
|
||||
@@ -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/scope/README.md
|
||||
README.md: a8fbe97ae3b59f223bb52e44860439803fda420c
|
||||
README.md: 1ed09acd7ccb864b16fb5d2ac4390264085d1c9e
|
||||
README.zh.md: af238232987c74e89cdc4e009d3d0c40f71b02d8
|
||||
|
||||
@@ -28,10 +28,10 @@ The registration context determines both visibility and ownership, preventing a
|
||||
|
||||
Scope-aware services define a concrete `ScopeLayer` that aggregates their heterogeneous tables and domain helpers. `ScopedLayers.effect()` accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. `notify` defaults to `true`; the supplied callback owns whether observer failures throw or are contained. `EntryValues` remains internal, the storage classes are imported from the package root rather than a `/store` subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the [shared scoped-layer storage Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md).
|
||||
|
||||
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
|
||||
Handing out a scoped context hands out the minting plugin's service-resolution API (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only scope-aware surfaces isolate state** — registries must file by `scopeOf()` and events must dispatch through `scopeTarget()`; an arbitrary Cordis service remains context-global merely because it is called through a scoped context.
|
||||
- **Only scope-aware APIs isolate state** — registries must file by `scopeOf()` and events must dispatch through `scopeTarget()`; an arbitrary Cordis service remains context-global merely because it is called through a scoped context.
|
||||
- **A context carries one nearest scope key** — the hierarchy lives in the key-level parent relation, not in context tags; nested scope CONTEXTS still shadow to a single tag, and multi-membership policy sets remain unsupported.
|
||||
- **Service reachability comes from the scope minter** — handing out `Scope.ctx` also hands out the minting plugin's injected service surface, so a broader minter cannot later be narrowed by the holder.
|
||||
- **Service reachability comes from the scope minter** — handing out `Scope.ctx` also hands out the minting plugin's injected services, so a broader minter cannot later be narrowed by the holder.
|
||||
|
||||
@@ -128,8 +128,8 @@ export interface CreateScopeOptions {
|
||||
|
||||
/**
|
||||
* Mint a scope under `ctx`. The scoped context inherits the minting plugin's
|
||||
* dependency surface and owns every registration made through it.
|
||||
* @param ctx - active context whose dependency surface the scope inherits.
|
||||
* dependency API and owns every registration made through it.
|
||||
* @param ctx - active context whose dependency API the scope inherits.
|
||||
* @param key - opaque identity used for listener routing.
|
||||
* @param options - optional scope-chain placement.
|
||||
* @returns the scoped context and exact/shared disposal boundaries.
|
||||
|
||||
@@ -34,27 +34,6 @@ export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSur
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts'
|
||||
|
||||
/**
|
||||
* Find the latest closed turn that entered at least one model step, ignoring
|
||||
* balanced no-step turns produced by rejection, empty input, or cancellation.
|
||||
* @param events - session events, or an owned suffix, to inspect.
|
||||
* @returns the latest matching turn end, or `undefined`.
|
||||
*/
|
||||
export function findLastMessageTurnEnd(
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEvent<'turn/end'> | undefined {
|
||||
const steppedTurns = new Set<number>()
|
||||
let latest: SessionEvent<'turn/end'> | undefined
|
||||
for (const event of events) {
|
||||
if (event.type === 'step/start') {
|
||||
steppedTurns.add(event.data.turn)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'turn/end' && steppedTurns.delete(event.data.turn)) latest = event
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
sessions: SessionStore
|
||||
|
||||
@@ -7,28 +7,11 @@ import SessionStore, {
|
||||
Session,
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
findLastMessageTurnEnd,
|
||||
snapshotSessionEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
|
||||
describe('Session', () => {
|
||||
it('finds the latest closed turn that entered a model step', () => {
|
||||
const session = Session.create(SessionId('last-message-turn'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } })
|
||||
|
||||
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
||||
|
||||
session.append('turn/start', { turn: 2 })
|
||||
session.append('step/start', { turn: 2, step: 1 })
|
||||
session.append('step/end', { turn: 2, step: 1 })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
|
||||
|
||||
expect(findLastMessageTurnEnd(session.events)?.data)
|
||||
.toEqual({ turn: 2, reason: { kind: 'max-tokens' } })
|
||||
})
|
||||
|
||||
it('exposes one stable readonly surface view', () => {
|
||||
const session = Session.create(SessionId('surface-view'))
|
||||
const surface = session.surface
|
||||
|
||||
@@ -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: cc7b323ae1de917e93e243e97bd5cf5937ecdca4
|
||||
README.md: 75d18712a02ecc72c2ea3a7203d2a7377cef87c7
|
||||
README.zh.md: 8d4ae42596483f77aa82b23a0b41168465e2b165
|
||||
|
||||
@@ -111,7 +111,7 @@ Tools optionally own pure `presentCall()` and `presentResult()` render intents,
|
||||
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search — grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob), with `truncated`/`total` so a UI never presents a capped result as complete; the view carries no result text and a search has no `card: 'search'` call-time analogue), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
|
||||
|
||||
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
|
||||
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct top-level calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
|
||||
|
||||
### Code Mode
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue }
|
||||
/**
|
||||
* Registry-private capabilities the bridge receives at construction — the
|
||||
* `requireRuntime` idiom: operations only the owning registry can mint stay
|
||||
* off its public service surface and flow here as closures instead.
|
||||
* off its public service API and flow here as closures instead.
|
||||
*/
|
||||
export interface RunCodeBridgeOptions {
|
||||
/** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */
|
||||
@@ -646,7 +646,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
|
||||
kind: 'execute',
|
||||
rawInput: args.code,
|
||||
}),
|
||||
// Deliberately no presentResult: the generic surface fallback keeps this
|
||||
// Deliberately no presentResult: the generic card fallback keeps this
|
||||
// title and reads durable result content without duplicating a large raw
|
||||
// result into the host view payload.
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ export { defineContentToolFixture, type ContentToolFixtureOptions } from './test
|
||||
|
||||
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
|
||||
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
|
||||
// stays the single public surface for tool producers and UI adapters.
|
||||
// stays the single public API for tool producers and UI adapters.
|
||||
export type {
|
||||
ToolCallKind,
|
||||
FileLocation,
|
||||
@@ -200,7 +200,7 @@ export interface ToolOutputDefinition {
|
||||
readonly schema: JsonSchemaNode
|
||||
/** Pure projection from validated arguments and value to Native/model content. */
|
||||
render(args: unknown, value: JsonValue): ContentBlock[]
|
||||
/** Pure replayable presentation projection, computed only for surface calls. */
|
||||
/** Pure replayable presentation projection, computed only for top-level calls. */
|
||||
presentationMeta?(args: unknown, value: JsonValue): JsonValue
|
||||
}
|
||||
|
||||
@@ -786,7 +786,7 @@ export class ToolRegistry extends Service {
|
||||
scope => new ToolLayer(scope),
|
||||
() => { this.ctx.emit('tools/change') },
|
||||
)
|
||||
/** Presentation for agents that declare none; {@link presentAs} shadows it per agent. */
|
||||
/** Presentation for scopes that declare none; {@link presentAs} shadows it per scope. */
|
||||
private readonly defaultMode: ToolPresentationMode
|
||||
private readonly maxParallelSubCalls: number
|
||||
/**
|
||||
@@ -811,7 +811,7 @@ export class ToolRegistry extends Service {
|
||||
|
||||
/**
|
||||
* The generated-SDK prompt section, registered globally by a code-mode
|
||||
* deployment and per agent by {@link presentAs}.
|
||||
* deployment and per scope by {@link presentAs}.
|
||||
*
|
||||
* The body regenerates from the CALLING scope, and renders empty for an
|
||||
* agent presenting natively — an agent that opted out under a code-mode
|
||||
@@ -880,12 +880,14 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Present this agent's tools in `mode` instead of the deployment default.
|
||||
* Present the calling scope's tools in `mode` instead of the deployment
|
||||
* default. Nearest scope on the chain wins, so a preset's standing
|
||||
* declaration covers every agent joined under it.
|
||||
*
|
||||
* Scoped only, and one declaration per agent: this is how an agent preset
|
||||
* composes a Code Mode agent beside native ones in the same process, and a
|
||||
* Scoped only, and one declaration per scope: this is how an agent preset
|
||||
* composes Code Mode agents beside native ones in the same process, and a
|
||||
* process-global override would be the `mode` config field instead.
|
||||
* @param mode - the presentation this agent's model sees.
|
||||
* @param mode - the presentation the covered agents' models see.
|
||||
* @returns the exact disposer that restores the deployment default.
|
||||
*/
|
||||
presentAs(mode: ToolPresentationMode): () => void {
|
||||
@@ -898,14 +900,14 @@ export class ToolRegistry extends Service {
|
||||
ctx,
|
||||
(layer) => {
|
||||
if (layer.mode !== undefined) {
|
||||
throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this agent; one composition selects one presentation`)
|
||||
throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; one composition selects one presentation`)
|
||||
}
|
||||
layer.mode = mode
|
||||
return () => { layer.mode = undefined }
|
||||
},
|
||||
{ label: 'tools.presentAs()' },
|
||||
)
|
||||
// The SDK section is per agent for the same reason the mode is. Under a
|
||||
// The SDK section is per scope for the same reason the mode is. Under a
|
||||
// deployment that already defaults to a code mode this shadows the
|
||||
// global registration with an identical body, which costs nothing and
|
||||
// keeps one rule instead of a case analysis.
|
||||
@@ -1007,7 +1009,7 @@ export class ToolRegistry extends Service {
|
||||
* Restrict global tools for the calling agent scope. Empty filters, unknown
|
||||
* names, scope-local names, and reserved transport names fail. Restrictions
|
||||
* intersect; scoped registrations remain visible.
|
||||
* @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).
|
||||
* @param filter - global-tool mask: `allow` (keep only) and/or `deny` (remove).
|
||||
* @returns the exact disposer that lifts this restriction.
|
||||
*/
|
||||
restrict(filter: ToolRestriction): () => void {
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface GenericCallView {
|
||||
/** Category for icon/treatment; defaults to `other` when omitted. */
|
||||
kind?: ToolCallKind
|
||||
/**
|
||||
* The salient input to surface in a detail/expanded view (e.g. a background
|
||||
* The salient input to show in a detail/expanded view (e.g. a background
|
||||
* task id). Omit to show nothing; a string renders as-is, an object as pretty
|
||||
* JSON. NOT the full raw args object unless that is genuinely what a reader wants.
|
||||
*/
|
||||
|
||||
@@ -493,7 +493,7 @@ export interface DefineToolOptions<S extends ParameterSchemaSpec, O extends Valu
|
||||
readonly schema: O
|
||||
/** Pure Native/model rendering of one validated canonical value. */
|
||||
render(args: InferArgs<S>, value: InferValue<NoInfer<O>>): ContentBlock[]
|
||||
/** Pure replayable presentation metadata for direct surface calls. */
|
||||
/** Pure replayable presentation metadata for direct top-level calls. */
|
||||
presentationMeta?(args: InferArgs<S>, value: InferValue<NoInfer<O>>): JsonValue
|
||||
}
|
||||
/** Optional positive cooperative timeout budget in milliseconds. */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Code Mode codegen: the pure projection from registered tool schemas to the TypeScript SDK
|
||||
* text the model programs against (the `tools:sdk` prompt section). Sibling of
|
||||
* `json-schema.ts` — `schemas()` (native function calling) and this module (the generated
|
||||
* `declare const tools` surface) are two projections of the same store.
|
||||
* `declare const tools` API) are two projections of the same store.
|
||||
* @module @deepseek-ai/dsh-tools/src/ts-types
|
||||
*/
|
||||
|
||||
|
||||
@@ -1229,7 +1229,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
const tool = ctx.tools.get(RUN_CODE_NAME)!
|
||||
|
||||
expect(result.content).toEqual([{ type: 'text', text }])
|
||||
// Surfaces keep the pending program title and render this durable content
|
||||
// Presenters keep the pending program title and render this durable content
|
||||
// through their generic fallback. Omitting a result view also prevents the
|
||||
// host frame from carrying the same raw content a second time.
|
||||
expect('presentResult' in tool).toBe(false)
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
Reference in New Issue
Block a user