fix(goal): simplify blockers into one durable phase
This commit is contained in:
@@ -254,10 +254,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
key: 'goals',
|
||||
summary: 'Goal service (`ctx.goals`) backed exclusively by the owning session log.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'resolveCreate(request: CreateGoalRequest): CreateGoalSpec',
|
||||
jsDoc: '/**\n * Materialize deployment defaults and validate one create request.\n * @param request - objective plus optional caller-selected round cap.\n * @returns detached, fully resolved create specification.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'get(agent: Agent): GoalView | undefined',
|
||||
jsDoc: '/**\n * Read the current goal for one exact live agent.\n * @param agent - owning live agent.\n * @returns a fresh view or `undefined` when no goal is current.\n * @throws {@link GoalError} when the agent is not the registry\'s live instance.\n */',
|
||||
@@ -283,16 +279,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'block(agent: Agent, ref: GoalRef): GoalView',
|
||||
jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the blocked view.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'markUsageLimited(agent: Agent, ref: GoalRef): GoalView',
|
||||
jsDoc: '/**\n * Mark an active goal stopped by an external usage limit.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the usage-limited view.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'markBudgetLimited(agent: Agent, ref: GoalRef): GoalView',
|
||||
jsDoc: '/**\n * Mark an active goal stopped at its configured round cap.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the budget-limited view.\n */',
|
||||
signature: 'block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView',
|
||||
jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param reason - policy-owned stable code and human-readable explanation.\n * @returns the blocked view with its durable reason.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'clear(agent: Agent, ref: GoalRef): GoalRef',
|
||||
@@ -1137,10 +1125,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'CreateGoalRequest',
|
||||
declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CreateGoalSpec',
|
||||
declaration: 'export interface CreateGoalSpec {\n readonly objective: string;\n readonly maxGoalRounds: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CreateSessionOptions',
|
||||
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}',
|
||||
@@ -1241,13 +1225,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'GoalActivation',
|
||||
declaration: 'export type GoalActivation = \'armed\' | \'disarmed\';',
|
||||
},
|
||||
{
|
||||
name: 'GoalBlockReason',
|
||||
declaration: 'export interface GoalBlockReason {\n readonly code: string;\n readonly message: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'GoalId',
|
||||
declaration: 'export type GoalId = Branded<\'GoalId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'GoalPhase',
|
||||
declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'usage-limited\' | \'budget-limited\' | \'complete\';',
|
||||
declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'complete\';',
|
||||
},
|
||||
{
|
||||
name: 'GoalRef',
|
||||
@@ -1255,7 +1243,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'GoalSnapshot',
|
||||
declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly maxGoalRounds: number;\n}',
|
||||
declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly blockedReason?: GoalBlockReason;\n readonly maxGoalRounds: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'GoalView',
|
||||
|
||||
@@ -11,13 +11,13 @@ Event-sourced same-session goal state. The service retains one current completio
|
||||
defaultMaxGoalRounds: 256
|
||||
```
|
||||
|
||||
`defaultMaxGoalRounds` must be a positive safe integer. `resolveCreate()` materializes this deployment default before `create()` commits a goal; a request-level value overrides it.
|
||||
`defaultMaxGoalRounds` must be a positive safe integer. `create()` materializes this deployment default internally before committing a goal; a request-level value overrides it.
|
||||
|
||||
## Service contract
|
||||
|
||||
`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, usage-limit, budget-limit, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md).
|
||||
`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). Creation default resolution is an internal implementation step, not an additional public verb.
|
||||
|
||||
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase and activation. Pause, completion, blocking, limit transitions, and clear disarm activation. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; an active armed goal rejects the redundant operation.
|
||||
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation.
|
||||
|
||||
Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The raw `context/message`, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
|
||||
|
||||
@@ -35,7 +35,7 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each mutation is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log.
|
||||
Each mutation is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. The descriptive XML delimiter follows this repository's existing `<workspace_context>` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { renderGoalChange } from './render.ts'
|
||||
import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
|
||||
import type {
|
||||
FoldedGoal,
|
||||
GoalBlockReason,
|
||||
GoalChangeMeta,
|
||||
GoalClearChangeMeta,
|
||||
GoalMessageSource,
|
||||
@@ -25,17 +26,8 @@ const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Se
|
||||
'resume',
|
||||
'complete',
|
||||
'block',
|
||||
'mark-usage-limited',
|
||||
'mark-budget-limited',
|
||||
])
|
||||
const PHASES: ReadonlySet<GoalPhase> = new Set([
|
||||
'active',
|
||||
'paused',
|
||||
'blocked',
|
||||
'usage-limited',
|
||||
'budget-limited',
|
||||
'complete',
|
||||
])
|
||||
const PHASES: ReadonlySet<GoalPhase> = new Set(['active', 'paused', 'blocked', 'complete'])
|
||||
|
||||
/** Mutable accumulator kept private to the pure fold. */
|
||||
export interface GoalFoldState {
|
||||
@@ -83,13 +75,24 @@ function nonNegativeInteger(value: unknown, field: string): number {
|
||||
return value
|
||||
}
|
||||
|
||||
/** Decode one canonical blocker explanation. */
|
||||
function decodeBlockReason(value: unknown): GoalBlockReason {
|
||||
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') {
|
||||
throw new Error('goal change goal.blockedReason has an invalid shape')
|
||||
}
|
||||
if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) {
|
||||
throw new Error('goal change goal.blockedReason.code must be lower-kebab-case')
|
||||
}
|
||||
if (typeof value['message'] !== 'string' || value['message'].trim().length === 0
|
||||
|| value['message'] !== value['message'].trim()) {
|
||||
throw new Error('goal change goal.blockedReason.message must be non-empty and normalized')
|
||||
}
|
||||
return { code: value['code'], message: value['message'] }
|
||||
}
|
||||
|
||||
/** Decode and validate one snapshot. */
|
||||
function decodeSnapshot(value: unknown): GoalSnapshot {
|
||||
if (!isRecord(value)) throw new Error('goal change goal must be a record')
|
||||
const keys = Object.keys(value).sort()
|
||||
if (keys.join(',') !== 'id,maxGoalRounds,objective,phase,revision') {
|
||||
throw new Error('goal change goal has an invalid shape')
|
||||
}
|
||||
if (typeof value['id'] !== 'string' || value['id'].length === 0) {
|
||||
throw new Error('goal change goal.id must be a non-empty string')
|
||||
}
|
||||
@@ -100,12 +103,20 @@ function decodeSnapshot(value: unknown): GoalSnapshot {
|
||||
if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) {
|
||||
throw new Error('goal change goal.phase is invalid')
|
||||
}
|
||||
const phase = value['phase'] as GoalPhase
|
||||
const expectedKeys = phase === 'blocked'
|
||||
? 'blockedReason,id,maxGoalRounds,objective,phase,revision'
|
||||
: 'id,maxGoalRounds,objective,phase,revision'
|
||||
if (Object.keys(value).sort().join(',') !== expectedKeys) {
|
||||
throw new Error('goal change goal has an invalid shape')
|
||||
}
|
||||
return {
|
||||
id: GoalId(value['id']),
|
||||
revision: positiveInteger(value['revision'], 'goal.revision'),
|
||||
objective: value['objective'],
|
||||
phase: value['phase'] as GoalPhase,
|
||||
phase,
|
||||
maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'),
|
||||
...phase === 'blocked' ? { blockedReason: decodeBlockReason(value['blockedReason']) } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +219,10 @@ function validateSnapshotTransition(
|
||||
}
|
||||
switch (change.operation) {
|
||||
case 'edit':
|
||||
if (next.phase !== current.phase) throw new Error('goal edit cannot change phase')
|
||||
if (next.phase !== current.phase
|
||||
|| JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) {
|
||||
throw new Error('goal edit cannot change phase or blocked reason')
|
||||
}
|
||||
break
|
||||
case 'pause':
|
||||
requireSameDefinition(current, next, change.operation)
|
||||
@@ -220,8 +234,6 @@ function validateSnapshotTransition(
|
||||
'active',
|
||||
'paused',
|
||||
'blocked',
|
||||
'usage-limited',
|
||||
'budget-limited',
|
||||
])
|
||||
if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) {
|
||||
throw new Error('goal resume has an invalid phase transition or exhausted round budget')
|
||||
@@ -236,19 +248,6 @@ function validateSnapshotTransition(
|
||||
requireSameDefinition(current, next, change.operation)
|
||||
if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition')
|
||||
break
|
||||
case 'mark-usage-limited':
|
||||
requireSameDefinition(current, next, change.operation)
|
||||
if (current.phase !== 'active' || next.phase !== 'usage-limited') {
|
||||
throw new Error('goal mark-usage-limited has an invalid phase transition')
|
||||
}
|
||||
break
|
||||
case 'mark-budget-limited':
|
||||
requireSameDefinition(current, next, change.operation)
|
||||
if (current.phase !== 'active' || next.phase !== 'budget-limited'
|
||||
|| state.roundsStarted < next.maxGoalRounds) {
|
||||
throw new Error('goal mark-budget-limited has an invalid phase transition or remaining round budget')
|
||||
}
|
||||
break
|
||||
/* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */
|
||||
case 'create':
|
||||
throw new Error('goal create cannot be validated as a current-goal transition')
|
||||
|
||||
@@ -27,9 +27,9 @@ import {
|
||||
} from './runtime.ts'
|
||||
import type {
|
||||
CreateGoalRequest,
|
||||
CreateGoalSpec,
|
||||
EditGoalRequest,
|
||||
GoalActivation,
|
||||
GoalBlockReason,
|
||||
GoalChangeMeta,
|
||||
GoalChanged,
|
||||
GoalClearChangeMeta,
|
||||
@@ -79,6 +79,12 @@ interface GoalCache {
|
||||
readonly pending: PendingGoalChange[]
|
||||
}
|
||||
|
||||
/** Validated create input with every deployment default materialized. */
|
||||
interface ResolvedCreateGoal {
|
||||
readonly objective: string
|
||||
readonly maxGoalRounds: number
|
||||
}
|
||||
|
||||
/** Validate a caller-visible positive safe-integer round cap. */
|
||||
function resolveMaxGoalRounds(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
@@ -95,6 +101,31 @@ function resolveObjective(value: string): string {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
/** Materialize deployment defaults and validate one create request. */
|
||||
function resolveCreateGoal(request: CreateGoalRequest, defaultMaxGoalRounds: number): ResolvedCreateGoal {
|
||||
return {
|
||||
objective: resolveObjective(request.objective),
|
||||
maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? defaultMaxGoalRounds),
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate and detach one policy-owned blocker explanation. */
|
||||
function resolveBlockReason(reason: unknown): GoalBlockReason {
|
||||
const record = typeof reason === 'object' && reason !== null && !Array.isArray(reason)
|
||||
? reason as Record<string, unknown>
|
||||
: undefined
|
||||
const code = record?.['code']
|
||||
const message = record?.['message']
|
||||
if (typeof code !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(code)
|
||||
|| typeof message !== 'string' || message.trim().length === 0) {
|
||||
throw new GoalError(
|
||||
'goal block reason requires a lower-kebab-case code and a non-empty message',
|
||||
'GOAL_INVALID_BLOCK_REASON',
|
||||
)
|
||||
}
|
||||
return { code, message: message.trim() }
|
||||
}
|
||||
|
||||
/** Compare the complete canonical payloads used for deferred reconciliation. */
|
||||
function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
@@ -121,18 +152,6 @@ export class GoalService extends Service {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize deployment defaults and validate one create request.
|
||||
* @param request - objective plus optional caller-selected round cap.
|
||||
* @returns detached, fully resolved create specification.
|
||||
*/
|
||||
resolveCreate(request: CreateGoalRequest): CreateGoalSpec {
|
||||
return {
|
||||
objective: resolveObjective(request.objective),
|
||||
maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? this.resolved.defaultMaxGoalRounds),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current goal for one exact live agent.
|
||||
* @param agent - owning live agent.
|
||||
@@ -154,7 +173,7 @@ export class GoalService extends Service {
|
||||
* @returns the created live view.
|
||||
*/
|
||||
create(agent: Agent, request: CreateGoalRequest): GoalView {
|
||||
const spec = this.resolveCreate(request)
|
||||
const spec = resolveCreateGoal(request, this.resolved.defaultMaxGoalRounds)
|
||||
const cache = this.prepareMutation(agent)
|
||||
const current = cache.state.goal
|
||||
if (current !== undefined && current.phase !== 'complete') {
|
||||
@@ -213,7 +232,7 @@ export class GoalService extends Service {
|
||||
resume(agent: Agent, ref: GoalRef): GoalView {
|
||||
const cache = this.prepareMutation(agent)
|
||||
const current = this.expectCurrent(cache, ref)
|
||||
const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked', 'usage-limited', 'budget-limited']
|
||||
const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked']
|
||||
if (!resumable.includes(current.phase)) {
|
||||
throw this.transitionError(current, 'resume', resumable)
|
||||
}
|
||||
@@ -240,7 +259,7 @@ export class GoalService extends Service {
|
||||
agent,
|
||||
ref,
|
||||
'complete',
|
||||
['active', 'paused', 'blocked', 'usage-limited', 'budget-limited'],
|
||||
['active', 'paused', 'blocked'],
|
||||
'complete',
|
||||
'disarmed',
|
||||
)
|
||||
@@ -250,45 +269,20 @@ export class GoalService extends Service {
|
||||
* Mark an active goal blocked and disarm it.
|
||||
* @param agent - owning live agent.
|
||||
* @param ref - expected current revision.
|
||||
* @returns the blocked view.
|
||||
* @param reason - policy-owned stable code and human-readable explanation.
|
||||
* @returns the blocked view with its durable reason.
|
||||
*/
|
||||
block(agent: Agent, ref: GoalRef): GoalView {
|
||||
return this.transition(agent, ref, 'block', ['active'], 'blocked', 'disarmed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an active goal stopped by an external usage limit.
|
||||
* @param agent - owning live agent.
|
||||
* @param ref - expected current revision.
|
||||
* @returns the usage-limited view.
|
||||
*/
|
||||
markUsageLimited(agent: Agent, ref: GoalRef): GoalView {
|
||||
return this.transition(agent, ref, 'mark-usage-limited', ['active'], 'usage-limited', 'disarmed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an active goal stopped at its configured round cap.
|
||||
* @param agent - owning live agent.
|
||||
* @param ref - expected current revision.
|
||||
* @returns the budget-limited view.
|
||||
*/
|
||||
markBudgetLimited(agent: Agent, ref: GoalRef): GoalView {
|
||||
block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView {
|
||||
const cache = this.prepareMutation(agent)
|
||||
const current = this.expectCurrent(cache, ref)
|
||||
if (current.phase !== 'active') {
|
||||
throw this.transitionError(current, 'mark-budget-limited', ['active'])
|
||||
}
|
||||
if (cache.state.roundsStarted < current.maxGoalRounds) {
|
||||
throw new GoalError(
|
||||
`goal "${current.id}" has started ${cache.state.roundsStarted}/${current.maxGoalRounds} rounds`,
|
||||
'GOAL_INVALID_TRANSITION',
|
||||
)
|
||||
throw this.transitionError(current, 'block', ['active'])
|
||||
}
|
||||
return this.commitCurrent(
|
||||
agent,
|
||||
cache,
|
||||
'mark-budget-limited',
|
||||
this.withPhase(current, 'budget-limited'),
|
||||
'block',
|
||||
{ ...this.withPhase(current, 'blocked'), blockedReason: resolveBlockReason(reason) },
|
||||
'disarmed',
|
||||
)
|
||||
}
|
||||
@@ -384,7 +378,13 @@ export class GoalService extends Service {
|
||||
|
||||
/** Build a new revision with one replacement phase. */
|
||||
private withPhase(current: GoalSnapshot, phase: GoalPhase): GoalSnapshot {
|
||||
return { ...current, revision: current.revision + 1, phase }
|
||||
return {
|
||||
id: current.id,
|
||||
revision: current.revision + 1,
|
||||
objective: current.objective,
|
||||
phase,
|
||||
maxGoalRounds: current.maxGoalRounds,
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared validated phase transition. */
|
||||
|
||||
@@ -22,16 +22,24 @@ export type GoalPhase =
|
||||
| 'active'
|
||||
| 'paused'
|
||||
| 'blocked'
|
||||
| 'usage-limited'
|
||||
| 'budget-limited'
|
||||
| 'complete'
|
||||
|
||||
/** Machine-routable and human-readable explanation for a blocked goal. */
|
||||
export interface GoalBlockReason {
|
||||
/** Stable lower-kebab-case classification chosen by the blocking policy. */
|
||||
readonly code: string
|
||||
/** Non-empty explanation shown to humans and models. */
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Full durable state written by every non-clear goal mutation. */
|
||||
export interface GoalSnapshot extends GoalRef {
|
||||
/** Human-requested completion objective. */
|
||||
readonly objective: string
|
||||
/** Durable lifecycle phase. */
|
||||
readonly phase: GoalPhase
|
||||
/** Present exactly while `phase` is `blocked`. */
|
||||
readonly blockedReason?: GoalBlockReason
|
||||
/** Total admitted goal-round cap. */
|
||||
readonly maxGoalRounds: number
|
||||
}
|
||||
@@ -59,8 +67,6 @@ export type GoalOperation =
|
||||
| 'resume'
|
||||
| 'complete'
|
||||
| 'block'
|
||||
| 'mark-usage-limited'
|
||||
| 'mark-budget-limited'
|
||||
| 'clear'
|
||||
|
||||
/** Full-snapshot goal mutation retained in a model-visible context event. */
|
||||
@@ -121,12 +127,6 @@ export interface CreateGoalRequest {
|
||||
readonly maxGoalRounds?: number
|
||||
}
|
||||
|
||||
/** Validated create input with every deployment default materialized. */
|
||||
export interface CreateGoalSpec {
|
||||
readonly objective: string
|
||||
readonly maxGoalRounds: number
|
||||
}
|
||||
|
||||
/** Fields changed by an edit; at least one must be present. */
|
||||
export interface EditGoalRequest {
|
||||
readonly objective?: string
|
||||
@@ -149,6 +149,7 @@ export type GoalErrorCode =
|
||||
| 'GOAL_STALE_REVISION'
|
||||
| 'GOAL_INVALID_OBJECTIVE'
|
||||
| 'GOAL_INVALID_MAX_ROUNDS'
|
||||
| 'GOAL_INVALID_BLOCK_REASON'
|
||||
| 'GOAL_INVALID_EDIT'
|
||||
| 'GOAL_INVALID_TRANSITION'
|
||||
|
||||
|
||||
@@ -111,17 +111,13 @@ function appendRound(session: Session, ref: GoalRef, round: number): void {
|
||||
}
|
||||
|
||||
describe('GoalService creation and replay', () => {
|
||||
it('resolves the configured default and writes one balanced raw context snapshot', async () => {
|
||||
it('applies the configured default and writes one balanced raw context snapshot', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_700_000_000_000)
|
||||
const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 })
|
||||
const seen: string[] = []
|
||||
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) })
|
||||
|
||||
expect(ctx.goals.resolveCreate({ objective: ' finish the feature ' })).toEqual({
|
||||
objective: 'finish the feature',
|
||||
maxGoalRounds: 17,
|
||||
})
|
||||
const goal = ctx.goals.create(agent, { objective: ' finish the feature ' })
|
||||
|
||||
expect(goal).toMatchObject({
|
||||
@@ -151,18 +147,19 @@ describe('GoalService creation and replay', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('uses 256 rounds by default and validates create input at the owning resolver', async () => {
|
||||
it('uses 256 rounds by default and validates create input inside create', async () => {
|
||||
const { ctx, agent } = await harness()
|
||||
expect(ctx.goals.resolveCreate({ objective: 'x' })).toEqual({ objective: 'x', maxGoalRounds: 256 })
|
||||
expect(() => ctx.goals.resolveCreate({ objective: ' ' })).toThrow(expect.objectContaining({
|
||||
expect(() => ctx.goals.create(agent, { objective: ' ' })).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_INVALID_OBJECTIVE',
|
||||
}))
|
||||
expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({
|
||||
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_INVALID_MAX_ROUNDS',
|
||||
}))
|
||||
expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError)
|
||||
expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError)
|
||||
expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1 })).toThrow(GoalError)
|
||||
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError)
|
||||
expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError)
|
||||
expect(() => ctx.goals.create(agent, {
|
||||
objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1,
|
||||
})).toThrow(GoalError)
|
||||
expect(ctx.goals.create(agent, { objective: 'x' }).maxGoalRounds).toBe(256)
|
||||
})
|
||||
|
||||
@@ -170,9 +167,10 @@ describe('GoalService creation and replay', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const goals = new GoalService(ctx)
|
||||
expect(goals.resolveCreate({ objective: 'direct' })).toEqual({
|
||||
objective: 'direct',
|
||||
maxGoalRounds: 256,
|
||||
const stub = stubAgent('goal-direct-construction')
|
||||
ctx.agents.register(stub.agent)
|
||||
expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({
|
||||
objective: 'direct', maxGoalRounds: 256,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -287,18 +285,19 @@ describe('GoalService mutations', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('supports pause, resume, block, usage-limit, and completion transitions', async () => {
|
||||
it('supports pause, resume, block, and completion transitions', async () => {
|
||||
const { ctx, agent } = await harness()
|
||||
let goal = ctx.goals.create(agent, { objective: 'lifecycle' })
|
||||
goal = ctx.goals.pause(agent, goal)
|
||||
expect(goal).toMatchObject({ phase: 'paused', activation: 'disarmed', revision: 2 })
|
||||
goal = ctx.goals.resume(agent, goal)
|
||||
expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 3 })
|
||||
goal = ctx.goals.block(agent, goal)
|
||||
expect(goal).toMatchObject({ phase: 'blocked', activation: 'disarmed' })
|
||||
goal = ctx.goals.resume(agent, goal)
|
||||
goal = ctx.goals.markUsageLimited(agent, goal)
|
||||
expect(goal.phase).toBe('usage-limited')
|
||||
goal = ctx.goals.block(agent, goal, { code: 'needs-input', message: 'A choice is required.' })
|
||||
expect(goal).toMatchObject({
|
||||
phase: 'blocked',
|
||||
blockedReason: { code: 'needs-input', message: 'A choice is required.' },
|
||||
activation: 'disarmed',
|
||||
})
|
||||
goal = ctx.goals.resume(agent, goal)
|
||||
goal = ctx.goals.pause(agent, goal)
|
||||
goal = ctx.goals.complete(agent, goal)
|
||||
@@ -307,15 +306,13 @@ describe('GoalService mutations', () => {
|
||||
})
|
||||
|
||||
it('allows completion from every stopped phase and replacement only after completion', async () => {
|
||||
const phases = ['paused', 'blocked', 'usage-limited'] as const
|
||||
const phases = ['paused', 'blocked'] as const
|
||||
for (const phase of phases) {
|
||||
const { ctx, agent } = await harness()
|
||||
let goal = ctx.goals.create(agent, { objective: phase })
|
||||
goal = phase === 'paused'
|
||||
? ctx.goals.pause(agent, goal)
|
||||
: phase === 'blocked'
|
||||
? ctx.goals.block(agent, goal)
|
||||
: ctx.goals.markUsageLimited(agent, goal)
|
||||
: ctx.goals.block(agent, goal, { code: 'test-blocker', message: 'Blocked for the test.' })
|
||||
const complete = ctx.goals.complete(agent, goal)
|
||||
const replacement = ctx.goals.create(agent, { objective: `after ${phase}` })
|
||||
expect(complete.phase).toBe('complete')
|
||||
@@ -333,32 +330,45 @@ describe('GoalService mutations', () => {
|
||||
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
|
||||
const paused = ctx.goals.pause(agent, goal)
|
||||
expect(() => ctx.goals.pause(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
|
||||
expect(() => ctx.goals.block(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
|
||||
expect(() => ctx.goals.markUsageLimited(agent, paused)).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_INVALID_TRANSITION',
|
||||
}))
|
||||
expect(() => ctx.goals.markBudgetLimited(agent, paused)).toThrow(expect.objectContaining({
|
||||
expect(() => ctx.goals.block(agent, paused, {
|
||||
code: 'test-blocker', message: 'Blocked for the test.',
|
||||
})).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_INVALID_TRANSITION',
|
||||
}))
|
||||
})
|
||||
|
||||
it('enforces the goal-round cap before budget limiting and resuming', async () => {
|
||||
it('records canonical blocker reasons and enforces the round cap on resume', async () => {
|
||||
const { ctx, agent, session } = await harness()
|
||||
let goal = ctx.goals.create(agent, { objective: 'bounded', maxGoalRounds: 2 })
|
||||
for (const reason of [null, [], { code: 1, message: 'invalid code' }, { code: 'round-limit', message: 1 }]) {
|
||||
expect(() => ctx.goals.block(agent, goal, reason as never)).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_INVALID_BLOCK_REASON',
|
||||
}))
|
||||
}
|
||||
expect(() => ctx.goals.block(agent, goal, {
|
||||
code: 'Not Canonical', message: 'invalid code',
|
||||
})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' }))
|
||||
expect(() => ctx.goals.block(agent, goal, {
|
||||
code: 'round-limit', message: ' ',
|
||||
})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' }))
|
||||
appendRound(session, goal, 1)
|
||||
expect(ctx.goals.get(agent)?.roundsStarted).toBe(1)
|
||||
expect(() => ctx.goals.markBudgetLimited(agent, goal)).toThrow(expect.objectContaining({
|
||||
code: 'GOAL_INVALID_TRANSITION',
|
||||
}))
|
||||
appendRound(session, goal, 2)
|
||||
goal = ctx.goals.markBudgetLimited(agent, goal)
|
||||
expect(goal).toMatchObject({ phase: 'budget-limited', roundsStarted: 2, activation: 'disarmed' })
|
||||
goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: ' Goal round limit reached. ' })
|
||||
expect(goal).toMatchObject({
|
||||
phase: 'blocked',
|
||||
blockedReason: { code: 'round-limit', message: 'Goal round limit reached.' },
|
||||
roundsStarted: 2,
|
||||
activation: 'disarmed',
|
||||
})
|
||||
expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' }))
|
||||
goal = ctx.goals.edit(agent, goal, { maxGoalRounds: 3 })
|
||||
expect(goal.blockedReason).toEqual({ code: 'round-limit', message: 'Goal round limit reached.' })
|
||||
goal = ctx.goals.resume(agent, goal)
|
||||
expect(goal).toMatchObject({ phase: 'active', maxGoalRounds: 3, activation: 'armed' })
|
||||
expect(goal.blockedReason).toBeUndefined()
|
||||
appendRound(session, goal, 3)
|
||||
goal = ctx.goals.markBudgetLimited(agent, goal)
|
||||
goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: 'Goal round limit reached.' })
|
||||
expect(ctx.goals.complete(agent, goal).phase).toBe('complete')
|
||||
})
|
||||
|
||||
@@ -598,7 +608,16 @@ describe('goal replay validation', () => {
|
||||
return {
|
||||
...current,
|
||||
operation,
|
||||
goal: { ...current.goal, revision: current.goal.revision + 1, phase },
|
||||
goal: {
|
||||
id: current.goal.id,
|
||||
revision: current.goal.revision + 1,
|
||||
objective: current.goal.objective,
|
||||
phase,
|
||||
...phase === 'blocked'
|
||||
? { blockedReason: { code: 'test-blocker', message: 'Blocked for replay validation.' } }
|
||||
: {},
|
||||
maxGoalRounds: current.goal.maxGoalRounds,
|
||||
},
|
||||
updatedAt: current.updatedAt + 1,
|
||||
...overrides,
|
||||
}
|
||||
@@ -694,9 +713,6 @@ describe('goal replay validation', () => {
|
||||
mutation(base, 'resume', 'paused'),
|
||||
mutation(base, 'complete', 'active'),
|
||||
mutation(base, 'block', 'active'),
|
||||
mutation(base, 'mark-usage-limited', 'active'),
|
||||
mutation(base, 'mark-budget-limited', 'active'),
|
||||
mutation(base, 'mark-budget-limited', 'budget-limited'),
|
||||
]
|
||||
for (const change of invalid) expect(() => foldPair(base, change)).toThrow()
|
||||
|
||||
@@ -782,6 +798,12 @@ describe('goal replay validation', () => {
|
||||
{ ...base.goal, objective: ' ' },
|
||||
{ ...base.goal, objective: ' padded ' },
|
||||
{ ...base.goal, phase: 'unknown' },
|
||||
{ ...base.goal, blockedReason: { code: 'unexpected', message: 'Only blocked goals have reasons.' } },
|
||||
{ ...base.goal, phase: 'blocked' },
|
||||
{ ...base.goal, phase: 'blocked', blockedReason: null },
|
||||
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: 'Valid.', extra: true } },
|
||||
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'NOT_CANONICAL', message: 'Bad code.' } },
|
||||
{ ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: ' padded ' } },
|
||||
{ ...base.goal, revision: 0 },
|
||||
{ ...base.goal, maxGoalRounds: -1 },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user