Merge remote-tracking branch 'origin/master' into worktree/acp-automation-protocol
# Conflicts: # .agents/notes/implemented/architecture/2026-06-14-session-persistence.md # .agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md # .agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md # .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md # .agents/notes/implemented/feature/2026-06-14-acp-multi-session.md # .agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md # .agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md # docs/architecture.i18n.yaml # docs/cookbook/extension-cookbook.i18n.yaml # docs/cookbook/extension-cookbook.md # docs/cookbook/extension-cookbook.zh.md # docs/core-data-structures/approval.md # docs/core-data-structures/user-interaction.md # docs/event-producer-consumer.md # docs/persistence-catalog.md # docs/testing.md # docs/tool-catalog.md # examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/permission-switching/session.jsonl # packages/goal/tool-goal/README.md # packages/ui/acp/README.md # packages/ui/acp/acp-feature-support.md # packages/ui/acp/src/index.ts # packages/ui/acp/tests/bridge.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/ui/acp/tests/turns.spec.ts
This commit is contained in:
@@ -6,9 +6,9 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal
|
||||
|
||||
- `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`.
|
||||
- `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`. Strict-schema empty-string and zero fillers count as omitted, while meaningful values remain limited to their action.
|
||||
|
||||
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. UI clients receive pure generic cards: read for `get_goal`, other for mutations.
|
||||
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. UI clients receive pure generic cards: read for `get_goal`, other for mutations. Mutation cards select the first meaningful action value and otherwise show the goal id, so accepted fillers never produce blank input.
|
||||
|
||||
All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON.
|
||||
|
||||
@@ -18,7 +18,7 @@ An autonomous goal round that successfully reports `complete` or `blocked` contr
|
||||
|
||||
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.
|
||||
`{ kind: 'user' }` is a host attestation. `Agent.followup()` 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.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE
|
||||
|
||||
/**
|
||||
* Whether host-attested human input appears in the current root-agent turn.
|
||||
* An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human
|
||||
* An omitted `Agent.followup()` / `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 {
|
||||
|
||||
@@ -132,6 +132,16 @@ function resolveConfig(config: Config): ResolvedConfig {
|
||||
return { blockedAfterConsecutiveRounds: blockedAfter }
|
||||
}
|
||||
|
||||
/** Whether optional text is meaningful rather than a strict-schema empty filler. */
|
||||
function hasText(value: string | undefined): value is string {
|
||||
return value !== undefined && value !== ''
|
||||
}
|
||||
|
||||
/** Whether an optional round cap is meaningful rather than a strict-schema zero filler. */
|
||||
function hasRoundCap(value: number | undefined): value is number {
|
||||
return value !== undefined && value !== 0
|
||||
}
|
||||
|
||||
/** Build the exact compare-and-set ref from model arguments. */
|
||||
function goalRef(goalId: string, revision: number): GoalRef {
|
||||
if (goalId.length === 0 || goalId !== goalId.trim()
|
||||
@@ -271,12 +281,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
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 },
|
||||
...hasText(args.objective) ? { objective: args.objective } : {},
|
||||
...hasRoundCap(args.max_goal_rounds) ? { maxGoalRounds: args.max_goal_rounds } : {},
|
||||
}
|
||||
if (args.action === 'edit') {
|
||||
requireDirectHuman(ctx, execution)
|
||||
if (args.blocked_reason !== undefined) {
|
||||
if (hasText(args.blocked_reason)) {
|
||||
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
|
||||
}
|
||||
const goal = ctx.goals.edit(execution.agent, ref, replacements)
|
||||
@@ -285,7 +295,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
if (args.action === 'pause' || args.action === 'resume') {
|
||||
requireDirectHuman(ctx, execution)
|
||||
if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) {
|
||||
if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds) || hasText(args.blocked_reason)) {
|
||||
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',
|
||||
@@ -298,13 +308,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return Promise.resolve(goalValue(goal))
|
||||
}
|
||||
const authority = completionAuthority(ctx, execution)
|
||||
if (args.objective !== undefined || args.max_goal_rounds !== undefined) {
|
||||
if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds)) {
|
||||
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) {
|
||||
if (args.action === 'complete' && hasText(args.blocked_reason)) {
|
||||
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
|
||||
}
|
||||
if (args.action === 'blocked'
|
||||
@@ -331,7 +341,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
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,
|
||||
hasText(args.blocked_reason)
|
||||
? args.blocked_reason
|
||||
: hasText(args.objective)
|
||||
? args.objective
|
||||
: hasRoundCap(args.max_goal_rounds) ? args.max_goal_rounds : args.goal_id,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 AgentRegistry, { agentEvents, AgentMessageId } 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'
|
||||
@@ -31,16 +31,19 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
session,
|
||||
get status() { return status },
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
steer() {},
|
||||
followup: () => AgentMessageId('stub'),
|
||||
queue: () => AgentMessageId('stub'),
|
||||
steer: () => AgentMessageId('stub'),
|
||||
inject(content: ContentBlock[], options?: InjectOptions) {
|
||||
const source = options?.source ?? { kind: 'user' }
|
||||
session.append('context/message', {
|
||||
const source = options?.source ?? { kind: 'plugin', plugin: '' }
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source,
|
||||
...options?.meta === undefined ? {} : { meta: options.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
send: () => AgentMessageId('stub'),
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
@@ -142,8 +145,17 @@ describe('goal tool registration and presentation', () => {
|
||||
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: 'edit',
|
||||
objective: 'ship', max_goal_rounds: 0, blocked_reason: '',
|
||||
})).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 'ship' })
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({
|
||||
goal_id: 'goal-1', revision: 2, action: 'edit',
|
||||
objective: '', max_goal_rounds: 8, blocked_reason: '',
|
||||
})).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 8 })
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({
|
||||
goal_id: 'goal-1', revision: 2, action: 'resume',
|
||||
objective: '', max_goal_rounds: 0, blocked_reason: '',
|
||||
})).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' })
|
||||
expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined()
|
||||
})
|
||||
@@ -228,7 +240,9 @@ describe('goal tool execution authority', () => {
|
||||
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 }
|
||||
// A distinct agent object over root's exact session: same id, not the live
|
||||
// registered instance, so the executor must reject it.
|
||||
const stale = stubAgent('goal-tool-stale', root.agent.session).agent
|
||||
const staleResult = await execute(ctx, 'get_goal', {}, stale, stale)
|
||||
expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
|
||||
@@ -423,6 +437,77 @@ describe('goal tool state transitions', () => {
|
||||
expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
})
|
||||
|
||||
it('accepts only empty fillers in fields unused by the selected action', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
openTurn(root, { kind: 'user' })
|
||||
let goal = ctx.goals.create(root.agent, { objective: 'valid' })
|
||||
|
||||
const edited = await execute(ctx, 'update_goal', {
|
||||
goal_id: goal.id,
|
||||
revision: goal.revision,
|
||||
action: 'edit',
|
||||
objective: 'edited',
|
||||
max_goal_rounds: 0,
|
||||
blocked_reason: '',
|
||||
}, root.agent)
|
||||
expect(resultGoal(edited)).toMatchObject({ objective: 'edited' })
|
||||
goal = ctx.goals.get(root.agent)!
|
||||
|
||||
const capped = await execute(ctx, 'update_goal', {
|
||||
goal_id: goal.id,
|
||||
revision: goal.revision,
|
||||
action: 'edit',
|
||||
objective: '',
|
||||
max_goal_rounds: 8,
|
||||
blocked_reason: '',
|
||||
}, root.agent)
|
||||
expect(resultGoal(capped)).toMatchObject({ objective: 'edited', maxGoalRounds: 8 })
|
||||
goal = ctx.goals.get(root.agent)!
|
||||
|
||||
const paused = await execute(ctx, 'update_goal', {
|
||||
goal_id: goal.id,
|
||||
revision: goal.revision,
|
||||
action: 'pause',
|
||||
objective: '',
|
||||
max_goal_rounds: 0,
|
||||
blocked_reason: '',
|
||||
}, root.agent)
|
||||
expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'edited' })
|
||||
goal = ctx.goals.get(root.agent)!
|
||||
|
||||
const resumed = await execute(ctx, 'update_goal', {
|
||||
goal_id: goal.id,
|
||||
revision: goal.revision,
|
||||
action: 'resume',
|
||||
objective: '',
|
||||
max_goal_rounds: 0,
|
||||
blocked_reason: '',
|
||||
}, root.agent)
|
||||
expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'edited' })
|
||||
goal = ctx.goals.get(root.agent)!
|
||||
|
||||
const blocked = await execute(ctx, 'update_goal', {
|
||||
goal_id: goal.id,
|
||||
revision: goal.revision,
|
||||
action: 'blocked',
|
||||
objective: '',
|
||||
max_goal_rounds: 0,
|
||||
blocked_reason: 'actual blocker',
|
||||
}, root.agent)
|
||||
expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked' })
|
||||
goal = ctx.goals.resume(root.agent, { id: goal.id, revision: goal.revision + 1 })
|
||||
|
||||
const complete = await execute(ctx, 'update_goal', {
|
||||
goal_id: goal.id,
|
||||
revision: goal.revision,
|
||||
action: 'complete',
|
||||
objective: '',
|
||||
max_goal_rounds: 0,
|
||||
blocked_reason: '',
|
||||
}, root.agent)
|
||||
expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited' })
|
||||
})
|
||||
|
||||
it('allows exact goal rounds to complete but not edit or pause', async () => {
|
||||
const { ctx, root } = await harness()
|
||||
const humanTurn = openTurn(root, { kind: 'user' })
|
||||
|
||||
Reference in New Issue
Block a user