Merge remote-tracking branch 'origin/master' into feat/send-unify
# Conflicts: # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts
This commit is contained in:
@@ -10,6 +10,8 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal
|
||||
|
||||
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.
|
||||
|
||||
All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON.
|
||||
|
||||
An autonomous goal round that successfully reports `complete` or `blocked` 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
|
||||
|
||||
@@ -54,6 +54,62 @@ const GET_DESCRIPTION =
|
||||
+ 'continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. '
|
||||
+ 'Call this before updating a goal.'
|
||||
|
||||
/** Canonical goal-tool output, matching the existing compact Native JSON. */
|
||||
type GoalToolValue =
|
||||
| { goal: null }
|
||||
| {
|
||||
goal: {
|
||||
id: string
|
||||
revision: number
|
||||
objective: string
|
||||
phase: GoalView['phase']
|
||||
roundsStarted: number
|
||||
maxGoalRounds: number
|
||||
blockedReason?: { code: string; message: string }
|
||||
}
|
||||
activation: GoalView['activation']
|
||||
}
|
||||
|
||||
const GOAL_VALUE_SCHEMA = {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
goal: { type: 'null', required: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
goal: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
id: { type: 'string', required: true },
|
||||
revision: { type: 'integer', required: true },
|
||||
objective: { type: 'string', required: true },
|
||||
phase: { type: 'string', required: true, enum: ['active', 'paused', 'blocked', 'complete'] },
|
||||
roundsStarted: { type: 'integer', required: true },
|
||||
maxGoalRounds: { type: 'integer', required: true },
|
||||
blockedReason: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
code: { type: 'string', required: true },
|
||||
message: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
activation: { type: 'string', required: true, enum: ['armed', 'disarmed'] },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const
|
||||
|
||||
/** 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. '
|
||||
@@ -89,9 +145,9 @@ function goalRef(goalId: string, revision: number): GoalRef {
|
||||
}
|
||||
|
||||
/** 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({
|
||||
function goalValue(goal: GoalView | undefined): GoalToolValue {
|
||||
if (goal === undefined) return { goal: null }
|
||||
return {
|
||||
goal: {
|
||||
id: goal.id,
|
||||
revision: goal.revision,
|
||||
@@ -99,10 +155,18 @@ function renderGoal(goal: GoalView | undefined): string {
|
||||
phase: goal.phase,
|
||||
roundsStarted: goal.roundsStarted,
|
||||
maxGoalRounds: goal.maxGoalRounds,
|
||||
...goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason },
|
||||
...goal.blockedReason === undefined ? {} : {
|
||||
blockedReason: { code: goal.blockedReason.code, message: goal.blockedReason.message },
|
||||
},
|
||||
},
|
||||
activation: goal.activation,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Reusable canonical output declaration for all three goal controls. */
|
||||
const GOAL_OUTPUT = {
|
||||
schema: GOAL_VALUE_SCHEMA,
|
||||
render: (_args: unknown, value: GoalToolValue) => [{ type: 'text' as const, text: JSON.stringify(value) }],
|
||||
}
|
||||
|
||||
/** Generic, args-only pending presentation shared by the goal tools. */
|
||||
@@ -144,12 +208,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
name: 'get_goal',
|
||||
description: GET_DESCRIPTION,
|
||||
parameters: {},
|
||||
output: GOAL_OUTPUT,
|
||||
execute(_args, exec) {
|
||||
const execution = goalToolExecution(ctx, exec)
|
||||
return Promise.resolve([{
|
||||
type: 'text',
|
||||
text: renderGoal(ctx.goals.get(execution.agent)),
|
||||
}])
|
||||
return Promise.resolve(goalValue(ctx.goals.get(execution.agent)))
|
||||
},
|
||||
presentCall: () => present('Read current goal', 'read'),
|
||||
}))
|
||||
@@ -168,6 +230,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
description: 'Optional positive safe-integer limit on automatic continuation rounds.',
|
||||
},
|
||||
},
|
||||
output: GOAL_OUTPUT,
|
||||
execute(args, exec) {
|
||||
const execution = goalToolExecution(ctx, exec)
|
||||
requireDirectHuman(ctx, execution)
|
||||
@@ -176,7 +239,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
|
||||
})
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
|
||||
return Promise.resolve(goalValue(goal))
|
||||
},
|
||||
presentCall: args => present('Create goal', 'other', args.objective),
|
||||
}))
|
||||
@@ -203,6 +266,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
description: 'Concrete blocking condition; required only with action blocked.',
|
||||
},
|
||||
},
|
||||
output: GOAL_OUTPUT,
|
||||
execute(args, exec) {
|
||||
const execution = goalToolExecution(ctx, exec)
|
||||
const ref = goalRef(args.goal_id, args.revision)
|
||||
@@ -217,10 +281,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const goal = ctx.goals.edit(execution.agent, ref, replacements)
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve([{
|
||||
type: 'text',
|
||||
text: renderGoal(goal),
|
||||
}])
|
||||
return Promise.resolve(goalValue(goal))
|
||||
}
|
||||
if (args.action === 'pause' || args.action === 'resume') {
|
||||
requireDirectHuman(ctx, execution)
|
||||
@@ -234,7 +295,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
? ctx.goals.pause(execution.agent, ref)
|
||||
: ctx.goals.resume(execution.agent, ref)
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
|
||||
return Promise.resolve(goalValue(goal))
|
||||
}
|
||||
const authority = completionAuthority(ctx, execution)
|
||||
if (args.objective !== undefined || args.max_goal_rounds !== undefined) {
|
||||
@@ -265,7 +326,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
message: args.blocked_reason as string,
|
||||
})
|
||||
observeMutation(terminalTurns, execution, authority.kind === 'goal-round')
|
||||
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
|
||||
return Promise.resolve(goalValue(goal))
|
||||
},
|
||||
presentCall: args => present(
|
||||
`${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`,
|
||||
|
||||
@@ -100,9 +100,12 @@ async function execute(
|
||||
/** Parse the compact JSON returned by a successful goal tool. */
|
||||
function resultJson(result: ToolExecutionResult): Record<string, unknown> {
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected goal tool success')
|
||||
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>
|
||||
const parsed = JSON.parse(block.text) as Record<string, unknown>
|
||||
expect(result.value).toEqual(parsed)
|
||||
return parsed
|
||||
}
|
||||
|
||||
/** Read the returned goal sub-object. */
|
||||
@@ -198,7 +201,7 @@ describe('goal tool execution authority', () => {
|
||||
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')
|
||||
expect(agentless.error?.info?.code).toBe('GOAL_TOOL_AGENT_REQUIRED')
|
||||
|
||||
openTurn(root, { kind: 'user' })
|
||||
const driverless = await ctx.tools.execute({
|
||||
@@ -208,12 +211,12 @@ describe('goal tool execution authority', () => {
|
||||
arguments: {},
|
||||
agent: root.agent,
|
||||
})
|
||||
expect(driverless.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
expect(driverless.error?.info?.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')
|
||||
expect(nonHuman.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
closeTurn(root, 2)
|
||||
|
||||
const child = stubAgent('goal-tool-child')
|
||||
@@ -221,7 +224,7 @@ describe('goal tool execution authority', () => {
|
||||
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')
|
||||
expect(childResult.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
})
|
||||
|
||||
it('rejects stale agent objects and agents outside running status through the executor', async () => {
|
||||
@@ -231,11 +234,11 @@ describe('goal tool execution authority', () => {
|
||||
// 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?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
expect(staleResult.error?.info?.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')
|
||||
expect(idleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
})
|
||||
|
||||
it('treats a fork resumed as a runtime root as direct-human authority', async () => {
|
||||
@@ -265,12 +268,12 @@ describe('goal tool execution authority', () => {
|
||||
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')
|
||||
expect(before.error?.info?.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')
|
||||
expect(after.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
})
|
||||
|
||||
it('rejects terminal reporting without human input or a current goal round', async () => {
|
||||
@@ -279,11 +282,11 @@ describe('goal tool execution authority', () => {
|
||||
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')
|
||||
expect(result.error?.info?.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')
|
||||
expect(malformed.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
})
|
||||
|
||||
it('accepts direct human steering in a goal-sourced root turn', async () => {
|
||||
@@ -311,7 +314,7 @@ describe('goal tool execution authority', () => {
|
||||
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')
|
||||
expect(result.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -382,7 +385,7 @@ describe('goal tool state transitions', () => {
|
||||
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')
|
||||
expect(invalidCreate.error?.info?.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,
|
||||
@@ -390,26 +393,26 @@ describe('goal tool state transitions', () => {
|
||||
action: 'pause',
|
||||
objective: 'not valid for pause',
|
||||
}, root.agent)
|
||||
expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
expect(replacement.error?.info?.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')
|
||||
expect(terminalUpdate.error?.info?.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')
|
||||
expect(blockedWithoutReason.error?.info?.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')
|
||||
expect(blockedWithEmptyReason.error?.info?.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')
|
||||
expect(completeWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
const editWithReason = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id,
|
||||
revision: created.revision,
|
||||
@@ -417,11 +420,11 @@ describe('goal tool state transitions', () => {
|
||||
objective: 'still valid',
|
||||
blocked_reason: 'Not valid for edit.',
|
||||
}, root.agent)
|
||||
expect(editWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
expect(editWithReason.error?.info?.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')
|
||||
expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
|
||||
})
|
||||
|
||||
it('allows exact goal rounds to complete but not edit or pause', async () => {
|
||||
@@ -433,7 +436,7 @@ describe('goal tool state transitions', () => {
|
||||
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')
|
||||
expect(edit.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
|
||||
const complete = await execute(ctx, 'update_goal', {
|
||||
goal_id: created.id, revision: created.revision, action: 'complete',
|
||||
}, root.agent)
|
||||
@@ -455,7 +458,7 @@ describe('goal tool state transitions', () => {
|
||||
action: 'blocked',
|
||||
blocked_reason: 'The required credential is still unavailable.',
|
||||
}, root.agent)
|
||||
expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD')
|
||||
expect(result.error?.info?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD')
|
||||
closeTurn(root, turn)
|
||||
}
|
||||
openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 })
|
||||
|
||||
Reference in New Issue
Block a user