Merge remote-tracking branch 'origin/codex/enforce-tool-cancellation' into worktree/explicit-turn-signal
# Conflicts: # docs/architecture.md # docs/cordis-catalog/events.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.md # packages/core/agent/src/types.ts # packages/ui/acp/src/index.ts # packages/ui/tui/src/index.ts # packages/ui/tui/tests/harness.ts
This commit is contained in:
76
packages/goal/tool-goal/README.md
Normal file
76
packages/goal/tool-goal/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# @deepseek-ai/dsh-tool-goal
|
||||
|
||||
The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal`, `create_goal`, and `update_goal`. The [goal-tool Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md) owns the authority split and Codex-shaped UX.
|
||||
|
||||
## Tools
|
||||
|
||||
- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation.
|
||||
- `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution.
|
||||
- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`.
|
||||
|
||||
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations.
|
||||
|
||||
An autonomous goal round that successfully reports `complete` or `blocked` contributes the existing terminal `agent/turn-stop` decision for that physical turn. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
|
||||
|
||||
## Authority
|
||||
|
||||
Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does.
|
||||
|
||||
`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority.
|
||||
|
||||
Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: tool-goal
|
||||
name: '@deepseek-ai/dsh-tool-goal'
|
||||
config:
|
||||
blockedAfterConsecutiveRounds: 3
|
||||
```
|
||||
|
||||
The value must be a positive safe integer. It supplies both the hard lower bound on model self-blocking and the number named in model guidance.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A fixed goal policy says when semantic human intent warrants creation, requires exact read-before-update refs, explains rearming after resume/fork, and limits completion/blocking claims. The configured threshold is interpolated into that guidance.
|
||||
|
||||
##### Goal policy
|
||||
|
||||
```markdown
|
||||
Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Small fixed input cost on every request where this plugin's prompt registration is in scope.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the plugin scope, configured threshold, and guidance text are unchanged. Activation, disposal, or configuration changes may invalidate reuse from this prompt section.
|
||||
|
||||
### Tool schemas and results
|
||||
|
||||
#### 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.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Schemas are prefix-stable while their definitions and visibility are unchanged. Calls, results, and resulting goal snapshots append after the reusable request prefix without invalidating earlier entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Semantic intent remains model judgment** — execution can prove direct human provenance, not whether a request is substantial enough to merit a goal.
|
||||
- **Same-condition blocking remains model judgment** — the runtime enforces distinct admitted-round count, not semantic equivalence of obstacles; an independent evaluator is deferred.
|
||||
- **No scheduling or direct human rendering** — these tools mutate state only; the same-session driver and [`dsh-command-goal`](../command-goal/README.md) are independent consumers of the same domain.
|
||||
- **Goal-round authority requires a driver** — the autonomous `complete`/`blocked` path is dormant unless a continuation driver admits goal-sourced user turns; mounting this tool package alone does not create them.
|
||||
- **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together.
|
||||
46
packages/goal/tool-goal/package.json
Normal file
46
packages/goal/tool-goal/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-goal",
|
||||
"description": "Model-facing same-session goal tools with execution-time authority checks",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
109
packages/goal/tool-goal/src/authority.ts
Normal file
109
packages/goal/tool-goal/src/authority.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/** Execution-time authority checks for the model-facing goal tools. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { GoalView } from '@deepseek-ai/dsh-goal'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
type TurnStartEvent = Extract<SessionEvent, { type: 'turn/start' }>
|
||||
|
||||
/** Current open turn plus the events accepted after its start boundary. */
|
||||
export interface GoalToolExecution {
|
||||
readonly agent: Agent
|
||||
readonly start: TurnStartEvent
|
||||
readonly events: readonly SessionEvent[]
|
||||
}
|
||||
|
||||
/** Hard authority granted to one state-changing call. */
|
||||
export type GoalToolAuthority =
|
||||
| { readonly kind: 'direct-human' }
|
||||
| { readonly kind: 'goal-round'; readonly goal: GoalView }
|
||||
|
||||
/** Throw one structured tool-policy failure. */
|
||||
function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never {
|
||||
throw new HarnessError(message, code)
|
||||
}
|
||||
|
||||
/** Locate the open turn enclosing a model tool call. */
|
||||
function openTurn(agent: Agent): { start: TurnStartEvent; events: readonly SessionEvent[] } {
|
||||
const events = agent.session.events
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const boundary = events[index]
|
||||
if (boundary?.type === 'turn/end') {
|
||||
reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED')
|
||||
}
|
||||
if (boundary?.type === 'turn/start') {
|
||||
return { start: boundary, events: events.slice(index + 1) }
|
||||
}
|
||||
}
|
||||
return reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and authenticate the calling agent and its driver boundary.
|
||||
* @param ctx - Context carrying the live agent registry.
|
||||
* @param exec - Tool execution metadata supplied by the registry.
|
||||
* @returns The authenticated agent and its current turn window.
|
||||
*/
|
||||
export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolExecution {
|
||||
const agent = exec.agent
|
||||
if (agent === undefined) {
|
||||
return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED')
|
||||
}
|
||||
if (ctx.agents.get(agent.id) !== agent || agent.status !== 'running'
|
||||
|| ctx.agents.currentInitiator() !== agent) {
|
||||
return reject(
|
||||
'goal tools require the exact live calling agent inside its active driver',
|
||||
'GOAL_TOOL_DRIVER_REQUIRED',
|
||||
)
|
||||
}
|
||||
return { agent, ...openTurn(agent) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether host-attested human input appears in the current root-agent turn.
|
||||
* An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human
|
||||
* producers must supply their own source rather than inheriting this authority.
|
||||
*/
|
||||
function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean {
|
||||
if (!ctx.agents.roots().includes(execution.agent)) return false
|
||||
return execution.events.some(event =>
|
||||
(event.type === 'user/message' || event.type === 'steering/message')
|
||||
&& event.data.source.kind === 'user')
|
||||
}
|
||||
|
||||
/** Whether this turn is the current goal's exact admitted round. */
|
||||
function isMatchingGoalRound(execution: GoalToolExecution, goal: GoalView): boolean {
|
||||
return execution.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'goal'
|
||||
&& event.data.source.goalId === goal.id
|
||||
&& event.data.source.revision === goal.revision
|
||||
&& event.data.source.round === goal.roundsStarted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authority originating in a human message accepted by a runtime root.
|
||||
* @param ctx - Context carrying the live agent graph.
|
||||
* @param execution - Authenticated current tool execution.
|
||||
*/
|
||||
export function requireDirectHuman(ctx: Context, execution: GoalToolExecution): void {
|
||||
if (hasDirectHumanInput(ctx, execution)) return
|
||||
reject('this goal operation requires a direct human turn on a top-level agent')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve completion authority from either direct human input or the exact goal round.
|
||||
* @param ctx - Context carrying live agents and goal state.
|
||||
* @param execution - Authenticated current tool execution.
|
||||
* @returns The direct-human or exact-goal-round authority grant.
|
||||
*/
|
||||
export function completionAuthority(ctx: Context, execution: GoalToolExecution): GoalToolAuthority {
|
||||
if (hasDirectHumanInput(ctx, execution)) return { kind: 'direct-human' }
|
||||
const goal = ctx.goals.get(execution.agent)
|
||||
if (goal !== undefined && isMatchingGoalRound(execution, goal)) {
|
||||
return { kind: 'goal-round', goal }
|
||||
}
|
||||
return reject('complete and blocked require a direct human turn or the current goal round')
|
||||
}
|
||||
276
packages/goal/tool-goal/src/index.ts
Normal file
276
packages/goal/tool-goal/src/index.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Model-facing `get_goal`, `create_goal`, and `update_goal` tools over the
|
||||
* persisted same-session goal domain.
|
||||
* @module @deepseek-ai/dsh-tool-goal
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
|
||||
import { 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'
|
||||
import {
|
||||
completionAuthority,
|
||||
goalToolExecution,
|
||||
requireDirectHuman,
|
||||
} from './authority.ts'
|
||||
import type { GoalToolExecution } from './authority.ts'
|
||||
|
||||
export const name = 'tool-goal'
|
||||
export const inject = ['agents', 'goals', 'tools', 'systemPrompt']
|
||||
|
||||
/** Model policy and hard lower bounds for goal-state updates. */
|
||||
export interface Config {
|
||||
/** Minimum admitted goal rounds before the model may self-report `blocked`. */
|
||||
blockedAfterConsecutiveRounds?: number
|
||||
}
|
||||
|
||||
/** Schemastery config for the goal-tool policy. */
|
||||
export const Config: z<Config> = z.object({
|
||||
blockedAfterConsecutiveRounds: z.number().step(1).min(1).default(3),
|
||||
})
|
||||
|
||||
/** Fully materialized tool policy. */
|
||||
interface ResolvedConfig {
|
||||
readonly blockedAfterConsecutiveRounds: number
|
||||
}
|
||||
|
||||
type UpdateAction = 'edit' | 'pause' | 'resume' | 'complete' | 'blocked'
|
||||
|
||||
const UPDATE_ACTIONS: UpdateAction[] = ['edit', 'pause', 'resume', 'complete', 'blocked']
|
||||
|
||||
const CREATE_DESCRIPTION =
|
||||
'Create one persisted same-session completion goal when the current direct human request '
|
||||
+ 'is a long-running objective that should continue across autonomous goal rounds. You may '
|
||||
+ 'infer that intent without requiring the user to say "create a goal". Do not use this for '
|
||||
+ 'trivial single-turn work. Execution rejects non-human and subagent authority.'
|
||||
|
||||
const GET_DESCRIPTION =
|
||||
'Read the current same-session goal, including its exact id/revision, objective, phase, completed '
|
||||
+ 'continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. '
|
||||
+ 'Call this before updating a goal.'
|
||||
|
||||
/** Render policy guidance with its deployment-selected blocked threshold. */
|
||||
function guidance(blockedAfter: number): string {
|
||||
return 'Use goal tools for one long-running completion objective in the current session. '
|
||||
+ 'create_goal may infer goal intent from a direct human request in any language; do not '
|
||||
+ 'create a goal for routine single-turn work. Call get_goal before update_goal and copy its '
|
||||
+ 'exact goal_id and revision. After session resume or fork, an active goal is disarmed: when '
|
||||
+ 'a human asks to continue or resume in any wording or language, use update_goal action '
|
||||
+ 'resume to rearm it. Mark complete only when the objective is actually achieved. Mark '
|
||||
+ `blocked only after the same blocking condition persists for at least ${blockedAfter} `
|
||||
+ 'consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, '
|
||||
+ 'or useful remaining work is not blocked.'
|
||||
}
|
||||
|
||||
/** Validate config even when apply is called directly outside Loader normalization. */
|
||||
function resolveConfig(config: Config): ResolvedConfig {
|
||||
const blockedAfter = config.blockedAfterConsecutiveRounds ?? 3
|
||||
if (!Number.isSafeInteger(blockedAfter) || blockedAfter < 1) {
|
||||
throw new TypeError('blockedAfterConsecutiveRounds must be a positive safe integer')
|
||||
}
|
||||
return { blockedAfterConsecutiveRounds: blockedAfter }
|
||||
}
|
||||
|
||||
/** Build the exact compare-and-set ref from model arguments. */
|
||||
function goalRef(goalId: string, revision: number): GoalRef {
|
||||
if (goalId.length === 0 || goalId !== goalId.trim()
|
||||
|| !Number.isSafeInteger(revision) || revision < 1) {
|
||||
throw new HarnessError(
|
||||
'goal_id must be non-empty and revision must be a positive safe integer',
|
||||
'GOAL_TOOL_INVALID_UPDATE',
|
||||
)
|
||||
}
|
||||
return { id: GoalId(goalId), revision }
|
||||
}
|
||||
|
||||
/** Stable compact model result; activation is an observation, not replay state. */
|
||||
function renderGoal(goal: GoalView | undefined): string {
|
||||
if (goal === undefined) return JSON.stringify({ goal: null })
|
||||
return JSON.stringify({
|
||||
goal: {
|
||||
id: goal.id,
|
||||
revision: goal.revision,
|
||||
objective: goal.objective,
|
||||
phase: goal.phase,
|
||||
roundsStarted: goal.roundsStarted,
|
||||
maxGoalRounds: goal.maxGoalRounds,
|
||||
...goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason },
|
||||
},
|
||||
activation: goal.activation,
|
||||
})
|
||||
}
|
||||
|
||||
/** Generic, args-only pending presentation shared by the goal tools. */
|
||||
function present(title: string, kind: 'read' | 'other', rawInput?: unknown): GenericCallView {
|
||||
return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } }
|
||||
}
|
||||
|
||||
/** Remember whether one autonomous terminal report should stop this turn. */
|
||||
function observeMutation(
|
||||
terminalTurns: WeakMap<Agent, number>,
|
||||
execution: GoalToolExecution,
|
||||
autonomousTerminal: boolean,
|
||||
): void {
|
||||
if (!autonomousTerminal) {
|
||||
terminalTurns.delete(execution.agent)
|
||||
return
|
||||
}
|
||||
terminalTurns.set(execution.agent, execution.start.data.turn)
|
||||
}
|
||||
|
||||
/** Register the three Codex-shaped goal tools and their shared policy section. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = resolveConfig(config)
|
||||
// A stale entry cannot match a later loop turn because turn numbers increase
|
||||
// monotonically within the agent's fixed session.
|
||||
const terminalTurns = new WeakMap<Agent, number>()
|
||||
ctx.on('agent/turn-stop', (agent, turn) => {
|
||||
if (terminalTurns.get(agent) !== turn) return undefined
|
||||
terminalTurns.delete(agent)
|
||||
return { action: 'stop' }
|
||||
})
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:goal',
|
||||
order: 114,
|
||||
text: guidance(resolved.blockedAfterConsecutiveRounds),
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'get_goal',
|
||||
description: GET_DESCRIPTION,
|
||||
parameters: {},
|
||||
execute(_args, exec) {
|
||||
const execution = goalToolExecution(ctx, exec)
|
||||
return Promise.resolve([{
|
||||
type: 'text',
|
||||
text: renderGoal(ctx.goals.get(execution.agent)),
|
||||
}])
|
||||
},
|
||||
presentCall: () => present('Read current goal', 'read'),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'create_goal',
|
||||
description: CREATE_DESCRIPTION,
|
||||
parameters: {
|
||||
objective: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The concrete completion objective inferred from the direct human request.',
|
||||
},
|
||||
max_goal_rounds: {
|
||||
type: 'number',
|
||||
description: 'Optional positive safe-integer limit on automatic continuation rounds.',
|
||||
},
|
||||
},
|
||||
execute(args, exec) {
|
||||
const execution = goalToolExecution(ctx, exec)
|
||||
requireDirectHuman(ctx, execution)
|
||||
const goal = ctx.goals.create(execution.agent, {
|
||||
objective: args.objective,
|
||||
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
|
||||
})
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
|
||||
},
|
||||
presentCall: args => present('Create goal', 'other', args.objective),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'update_goal',
|
||||
description: 'Update the exact current goal revision. edit, pause, and resume require a direct '
|
||||
+ 'top-level human request. During an automatic continuation of the current goal, complete '
|
||||
+ 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains '
|
||||
+ 'responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.',
|
||||
parameters: {
|
||||
goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' },
|
||||
revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' },
|
||||
action: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: UPDATE_ACTIONS,
|
||||
description: 'edit | pause | resume | complete | blocked',
|
||||
},
|
||||
objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' },
|
||||
max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' },
|
||||
blocked_reason: {
|
||||
type: 'string',
|
||||
description: 'Concrete blocking condition; required only with action blocked.',
|
||||
},
|
||||
},
|
||||
execute(args, exec) {
|
||||
const execution = goalToolExecution(ctx, exec)
|
||||
const ref = goalRef(args.goal_id, args.revision)
|
||||
const replacements = {
|
||||
...args.objective === undefined ? {} : { objective: args.objective },
|
||||
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
|
||||
}
|
||||
if (args.action === 'edit') {
|
||||
requireDirectHuman(ctx, execution)
|
||||
if (args.blocked_reason !== undefined) {
|
||||
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
|
||||
}
|
||||
const goal = ctx.goals.edit(execution.agent, ref, replacements)
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve([{
|
||||
type: 'text',
|
||||
text: renderGoal(goal),
|
||||
}])
|
||||
}
|
||||
if (args.action === 'pause' || args.action === 'resume') {
|
||||
requireDirectHuman(ctx, execution)
|
||||
if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) {
|
||||
throw new HarnessError(
|
||||
'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked',
|
||||
'GOAL_TOOL_INVALID_UPDATE',
|
||||
)
|
||||
}
|
||||
const goal = args.action === 'pause'
|
||||
? ctx.goals.pause(execution.agent, ref)
|
||||
: ctx.goals.resume(execution.agent, ref)
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
|
||||
}
|
||||
const authority = completionAuthority(ctx, execution)
|
||||
if (args.objective !== undefined || args.max_goal_rounds !== undefined) {
|
||||
throw new HarnessError(
|
||||
'objective and max_goal_rounds are valid only with action edit',
|
||||
'GOAL_TOOL_INVALID_UPDATE',
|
||||
)
|
||||
}
|
||||
if (args.action === 'complete' && args.blocked_reason !== undefined) {
|
||||
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
|
||||
}
|
||||
if (args.action === 'blocked'
|
||||
&& (args.blocked_reason === undefined || args.blocked_reason.trim().length === 0)) {
|
||||
throw new HarnessError('blocked_reason is required with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
|
||||
}
|
||||
if (args.action === 'blocked' && authority.kind === 'goal-round'
|
||||
&& authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) {
|
||||
throw new HarnessError(
|
||||
`blocked requires at least ${resolved.blockedAfterConsecutiveRounds} consecutive goal rounds; `
|
||||
+ `current round is ${authority.goal.roundsStarted}`,
|
||||
'GOAL_TOOL_BLOCK_THRESHOLD',
|
||||
)
|
||||
}
|
||||
const goal = args.action === 'complete'
|
||||
? ctx.goals.complete(execution.agent, ref)
|
||||
: ctx.goals.block(execution.agent, ref, {
|
||||
code: 'model-reported',
|
||||
message: args.blocked_reason as string,
|
||||
})
|
||||
observeMutation(terminalTurns, execution, authority.kind === 'goal-round')
|
||||
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
|
||||
},
|
||||
presentCall: args => present(
|
||||
`${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`,
|
||||
'other',
|
||||
args.blocked_reason ?? args.objective ?? args.goal_id,
|
||||
),
|
||||
}))
|
||||
}
|
||||
490
packages/goal/tool-goal/tests/tool-goal.spec.ts
Normal file
490
packages/goal/tool-goal/tests/tool-goal.spec.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
|
||||
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
interface StubAgent {
|
||||
readonly agent: Agent
|
||||
readonly session: Session
|
||||
setStatus(status: AgentStatus): void
|
||||
}
|
||||
|
||||
/** Build one registry-compatible live agent whose injections append in place. */
|
||||
function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
const session = supplied ?? new Session(SessionId(rawId))
|
||||
let status: AgentStatus = 'running'
|
||||
const agent: Agent = {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
get status() { return status },
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
steer() {},
|
||||
inject(content: ContentBlock[], options?: InjectOptions) {
|
||||
const source = options?.source ?? { kind: 'user' }
|
||||
session.append('context/message', {
|
||||
content,
|
||||
source,
|
||||
...options?.meta === undefined ? {} : { meta: options.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
return { agent, session, setStatus(value) { status = value } }
|
||||
}
|
||||
|
||||
/** Open one message-triggered turn with its accepted model-visible input. */
|
||||
function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): number {
|
||||
const turn = stub.session.events
|
||||
.filter(event => event.type === 'turn/start')
|
||||
.reduce((max, event) => Math.max(max, event.data.turn), 0) + 1
|
||||
stub.session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
stub.session.append('user/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source,
|
||||
}, { surfaceOp: 'append' })
|
||||
return turn
|
||||
}
|
||||
|
||||
/** Close the currently open test turn. */
|
||||
function closeTurn(stub: StubAgent, turn: number): void {
|
||||
stub.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
async function harness(config: toolGoal.Config = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
const fiber = await ctx.plugin(toolGoal, config)
|
||||
const root = stubAgent(`goal-tool-root-${Math.random()}`)
|
||||
ctx.agents.register(root.agent)
|
||||
return { ctx, fiber, root }
|
||||
}
|
||||
|
||||
/** Execute one registered tool under an optional driver initiator. */
|
||||
async function execute(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
args: unknown,
|
||||
agent?: Agent,
|
||||
initiator: Agent | undefined = agent,
|
||||
): Promise<ToolExecutionResult> {
|
||||
const run = () => ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`call-${Math.random()}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agent === undefined ? {} : { agent },
|
||||
})
|
||||
return initiator === undefined ? run() : ctx.agents.withInitiator(initiator, run)
|
||||
}
|
||||
|
||||
/** Parse the compact JSON returned by a successful goal tool. */
|
||||
function resultJson(result: ToolExecutionResult): Record<string, unknown> {
|
||||
expect(result.isError).toBe(false)
|
||||
const block = result.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected text tool result')
|
||||
return JSON.parse(block.text) as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Read the returned goal sub-object. */
|
||||
function resultGoal(result: ToolExecutionResult): Record<string, unknown> {
|
||||
const goal = resultJson(result)['goal']
|
||||
if (typeof goal !== 'object' || goal === null) throw new Error('expected returned goal')
|
||||
return goal as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('goal tool registration and presentation', () => {
|
||||
it('registers three exclusive tools plus configured guidance and disposes all contributions', async () => {
|
||||
const { ctx, fiber } = await harness({ blockedAfterConsecutiveRounds: 5 })
|
||||
expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name))
|
||||
.toEqual(['create_goal', 'get_goal', 'update_goal'])
|
||||
for (const name of ['create_goal', 'get_goal', 'update_goal']) {
|
||||
expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId(name), name, arguments: {} }))
|
||||
.toEqual({ kind: 'exclusive' })
|
||||
}
|
||||
const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal')
|
||||
expect(section?.text).toContain('infer goal intent')
|
||||
expect(section?.text).toContain('at least 5 consecutive goal rounds')
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.get('get_goal')).toBeUndefined()
|
||||
expect((await ctx.systemPrompt.assemble()).sections.some(item => item.name === 'tool:goal')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses args-only generic render intent and soft-fails malformed replay args', async () => {
|
||||
const { ctx } = await harness()
|
||||
expect(ctx.tools.get('get_goal')?.presentCall?.({})).toEqual({
|
||||
card: 'generic', title: 'Read current goal', kind: 'read',
|
||||
})
|
||||
expect(ctx.tools.get('create_goal')?.presentCall?.({ objective: 'ship' })).toEqual({
|
||||
card: 'generic', title: 'Create goal', kind: 'other', rawInput: 'ship',
|
||||
})
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({
|
||||
goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.',
|
||||
})).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' })
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({
|
||||
goal_id: 'goal-1', revision: 2, action: 'resume',
|
||||
})).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' })
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('has the Loader-safe namespace export shape', () => {
|
||||
expect('default' in toolGoal).toBe(false)
|
||||
expect(toolGoal.name).toBe('tool-goal')
|
||||
expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
expect(loader.unwrapExports(toolGoal)).toBe(toolGoal)
|
||||
})
|
||||
|
||||
it('fails invalid direct config before registering anything', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
expect(() => {
|
||||
toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 })
|
||||
}).toThrow(
|
||||
'blockedAfterConsecutiveRounds must be a positive safe integer',
|
||||
)
|
||||
expect(ctx.tools.get('get_goal')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves the direct-apply default before registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
toolGoal.apply(ctx, {})
|
||||
const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal')
|
||||
expect(section?.text).toContain('at least 3 consecutive goal rounds')
|
||||
})
|
||||
})
|
||||
|
||||
describe('goal tool execution authority', () => {
|
||||
it('lets a root model infer create intent from its accepted human turn', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' }, '请持续工作直到这个功能完成')
|
||||
const result = await execute(ctx, 'create_goal', {
|
||||
objective: 'Finish the feature', max_goal_rounds: 9,
|
||||
}, root.agent)
|
||||
expect(resultGoal(result)).toMatchObject({
|
||||
objective: 'Finish the feature', revision: 1, phase: 'active', maxGoalRounds: 9,
|
||||
})
|
||||
expect(resultJson(result)['activation']).toBe('armed')
|
||||
expect(ctx.goals.get(root.agent)?.objective).toBe('Finish the feature')
|
||||
})
|
||||
|
||||
it('rejects agentless, driverless, non-human, and live-child creation', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const agentless = await execute(ctx, 'get_goal', {})
|
||||
expect(agentless.error?.code).toBe('GOAL_TOOL_AGENT_REQUIRED')
|
||||
|
||||
openTurn(root, { kind: 'user' })
|
||||
const driverless = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('call-driverless'),
|
||||
name: 'get_goal',
|
||||
arguments: {},
|
||||
agent: root.agent,
|
||||
})
|
||||
expect(driverless.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
closeTurn(root, 1)
|
||||
|
||||
openTurn(root, { kind: 'plugin', plugin: 'test' })
|
||||
const nonHuman = await execute(ctx, 'create_goal', { objective: 'forged' }, root.agent)
|
||||
expect(nonHuman.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
closeTurn(root, 2)
|
||||
|
||||
const child = stubAgent('goal-tool-child')
|
||||
ctx.agents.enter(child.agent, root.agent)
|
||||
ctx.agents.announce(child.agent)
|
||||
openTurn(child, { kind: 'user' })
|
||||
const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent)
|
||||
expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
})
|
||||
|
||||
it('rejects stale agent objects and agents outside running status through the executor', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' })
|
||||
const stale = { ...root.agent }
|
||||
const staleResult = await execute(ctx, 'get_goal', {}, stale, stale)
|
||||
expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
|
||||
root.setStatus('idle')
|
||||
const idleResult = await execute(ctx, 'get_goal', {}, root.agent)
|
||||
expect(idleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
})
|
||||
|
||||
it('treats a fork resumed as a runtime root as direct-human authority', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const originalTurn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'resume the fork' })
|
||||
closeTurn(root, originalTurn)
|
||||
const forkId = SessionId('goal-tool-resumed-fork')
|
||||
const forkSession = new Session(forkId, root.session.events, {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: forkId,
|
||||
createdAt: Date.now(),
|
||||
parentSession: root.session.id,
|
||||
seedLength: root.session.seq,
|
||||
})
|
||||
const fork = stubAgent(forkId, forkSession)
|
||||
ctx.agents.register(fork.agent)
|
||||
expect(ctx.goals.get(fork.agent)).toMatchObject({ id: created.id, activation: 'disarmed' })
|
||||
|
||||
openTurn(fork, { kind: 'user' }, '继续这个目标')
|
||||
const resumed = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'resume',
|
||||
}, fork.agent)
|
||||
expect(resultGoal(resumed)).toMatchObject({ id: created.id, revision: 2, phase: 'active' })
|
||||
})
|
||||
|
||||
it('rejects calls before a turn and after its end boundary', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const before = await execute(ctx, 'get_goal', {}, root.agent)
|
||||
expect(before.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
|
||||
const turn = openTurn(root, { kind: 'user' })
|
||||
closeTurn(root, turn)
|
||||
const after = await execute(ctx, 'get_goal', {}, root.agent)
|
||||
expect(after.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
})
|
||||
|
||||
it('rejects terminal reporting without human input or a current goal round', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'plugin', plugin: 'test' })
|
||||
const result = await execute(ctx, 'update_goal', {
|
||||
goal_id: 'goal-missing', revision: 1, action: 'complete',
|
||||
}, root.agent)
|
||||
expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
const malformed = await execute(ctx, 'update_goal', {
|
||||
goal_id: 'goal-missing', revision: 1, action: 'pause', objective: 'probe',
|
||||
}, root.agent)
|
||||
expect(malformed.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
})
|
||||
|
||||
it('accepts direct human steering in a goal-sourced root turn', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const humanTurn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'steer me' })
|
||||
closeTurn(root, humanTurn)
|
||||
const round = openTurn(root, {
|
||||
kind: 'goal', goalId: created.id, revision: created.revision, round: 1,
|
||||
})
|
||||
root.session.append('steering/message', {
|
||||
turn: round,
|
||||
content: [{ type: 'text', text: 'pause now' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const paused = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'pause',
|
||||
}, root.agent)
|
||||
expect(resultGoal(paused)).toMatchObject({ phase: 'paused', revision: 2 })
|
||||
})
|
||||
|
||||
it('rejects an initiator different from exec.agent', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const other = stubAgent('goal-tool-other')
|
||||
ctx.agents.register(other.agent)
|
||||
openTurn(other, { kind: 'user' })
|
||||
const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent)
|
||||
expect(result.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('goal tool state transitions', () => {
|
||||
it('reads null, then edits, pauses, and resumes by exact revision in one human turn', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' })
|
||||
expect(resultJson(await execute(ctx, 'get_goal', {}, root.agent))).toEqual({ goal: null })
|
||||
let goal = resultGoal(await execute(ctx, 'create_goal', { objective: 'old' }, root.agent))
|
||||
goal = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: goal['id'], revision: goal['revision'], action: 'edit',
|
||||
objective: 'new', max_goal_rounds: 8,
|
||||
}, root.agent))
|
||||
expect(goal).toMatchObject({ objective: 'new', revision: 2, maxGoalRounds: 8 })
|
||||
goal = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: goal['id'], revision: goal['revision'], action: 'pause',
|
||||
}, root.agent))
|
||||
expect(goal).toMatchObject({ phase: 'paused', revision: 3 })
|
||||
goal = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: goal['id'], revision: goal['revision'], action: 'resume',
|
||||
}, root.agent))
|
||||
expect(goal).toMatchObject({ phase: 'active', revision: 4 })
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1, testToolSignal)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('terminal-stops 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' })
|
||||
const paused = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'pause',
|
||||
}, root.agent)
|
||||
expect(resultGoal(paused)).toMatchObject({ phase: 'paused' })
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', humanTurn, testToolSignal)).toBeUndefined()
|
||||
const resumed = resultGoal(await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: 2, action: 'resume',
|
||||
}, root.agent))
|
||||
closeTurn(root, humanTurn)
|
||||
|
||||
const roundTurn = openTurn(root, {
|
||||
kind: 'goal', goalId: created.id, revision: resumed['revision'] as number, round: 1,
|
||||
})
|
||||
const complete = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: resumed['revision'], action: 'complete',
|
||||
}, root.agent)
|
||||
expect(resultGoal(complete)).toMatchObject({ phase: 'complete' })
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn, testToolSignal)).toEqual({ action: 'stop' })
|
||||
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn, testToolSignal)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rearms a restored active goal only after a new direct human prompt', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
let turn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'continue later' })
|
||||
closeTurn(root, turn)
|
||||
agentEvents(ctx, root.agent).emit('agent/session-start', 'resume')
|
||||
expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed')
|
||||
turn = openTurn(root, { kind: 'user' }, '继续')
|
||||
const resumed = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'resume',
|
||||
}, root.agent)
|
||||
expect(resultGoal(resumed)).toMatchObject({ phase: 'active', revision: 2 })
|
||||
expect(resultJson(resumed)['activation']).toBe('armed')
|
||||
closeTurn(root, turn)
|
||||
})
|
||||
|
||||
it('returns structured domain and conditional-argument failures', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' })
|
||||
const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent)
|
||||
expect(invalidCreate.error?.code).toBe('GOAL_INVALID_OBJECTIVE')
|
||||
const created = ctx.goals.create(root.agent, { objective: 'valid' })
|
||||
const replacement = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id,
|
||||
revision: created.revision,
|
||||
action: 'pause',
|
||||
objective: 'not valid for pause',
|
||||
}, root.agent)
|
||||
expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const terminalUpdate = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id,
|
||||
revision: created.revision,
|
||||
action: 'complete',
|
||||
max_goal_rounds: 2,
|
||||
}, root.agent)
|
||||
expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const blockedWithoutReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'blocked',
|
||||
}, root.agent)
|
||||
expect(blockedWithoutReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const blockedWithEmptyReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ',
|
||||
}, root.agent)
|
||||
expect(blockedWithEmptyReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const completeWithReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.',
|
||||
}, root.agent)
|
||||
expect(completeWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const editWithReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id,
|
||||
revision: created.revision,
|
||||
action: 'edit',
|
||||
objective: 'still valid',
|
||||
blocked_reason: 'Not valid for edit.',
|
||||
}, root.agent)
|
||||
expect(editWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const malformedRef = await execute(ctx, 'update_goal', {
|
||||
goal_id: '', revision: 0, action: 'edit', objective: 'x',
|
||||
}, root.agent)
|
||||
expect(malformedRef.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
})
|
||||
|
||||
it('allows exact goal rounds to complete but not edit or pause', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const humanTurn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'round-owned' })
|
||||
closeTurn(root, humanTurn)
|
||||
openTurn(root, { kind: 'goal', goalId: created.id, revision: created.revision, round: 1 })
|
||||
const edit = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'edit', objective: 'forbidden',
|
||||
}, root.agent)
|
||||
expect(edit.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
const complete = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'complete',
|
||||
}, root.agent)
|
||||
expect(resultGoal(complete)).toMatchObject({ phase: 'complete', revision: 2, roundsStarted: 1 })
|
||||
})
|
||||
|
||||
it('enforces the configured model self-block lower bound across admitted rounds', async () => {
|
||||
const { ctx, root } = await harness({ blockedAfterConsecutiveRounds: 3 })
|
||||
let turn = openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'blocked eventually' })
|
||||
closeTurn(root, turn)
|
||||
const ref: GoalRef = { id: GoalId(created.id), revision: created.revision }
|
||||
|
||||
for (let round = 1; round <= 2; round += 1) {
|
||||
turn = openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round })
|
||||
const result = await execute(ctx, 'update_goal', {
|
||||
goal_id: ref.id,
|
||||
revision: ref.revision,
|
||||
action: 'blocked',
|
||||
blocked_reason: 'The required credential is still unavailable.',
|
||||
}, root.agent)
|
||||
expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD')
|
||||
closeTurn(root, turn)
|
||||
}
|
||||
openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 })
|
||||
const blocked = await execute(ctx, 'update_goal', {
|
||||
goal_id: ref.id,
|
||||
revision: ref.revision,
|
||||
action: 'blocked',
|
||||
blocked_reason: 'The required credential is still unavailable.',
|
||||
}, root.agent)
|
||||
expect(resultGoal(blocked)).toMatchObject({
|
||||
phase: 'blocked',
|
||||
blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' },
|
||||
roundsStarted: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('lets direct human authority block before the model threshold', async () => {
|
||||
const { ctx, root } = await harness({ blockedAfterConsecutiveRounds: 9 })
|
||||
openTurn(root, { kind: 'user' })
|
||||
const created = ctx.goals.create(root.agent, { objective: 'human stop' })
|
||||
const blocked = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id,
|
||||
revision: created.revision,
|
||||
action: 'blocked',
|
||||
blocked_reason: 'The user asked to stop until a prerequisite is available.',
|
||||
}, root.agent)
|
||||
expect(resultGoal(blocked)).toMatchObject({
|
||||
phase: 'blocked',
|
||||
blockedReason: {
|
||||
code: 'model-reported',
|
||||
message: 'The user asked to stop until a prerequisite is available.',
|
||||
},
|
||||
roundsStarted: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
39
packages/goal/tool-goal/tsconfig.json
Normal file
39
packages/goal/tool-goal/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../goal"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user