Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 31 additions & 18 deletions src/lib/agent/runner/harness/pi/orchestrator-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import {
applyReadHandoffs,
ENQUEUE_MODEL_DESCRIPTION,
HANDOFF_FIELDS,
NOT_NEEDED_REASON_ASK,
REMARK_ASK,
type CompleteArgs,
type EnqueueArgs,
type OrchestratorToolsContext,
} from '../../sequence/orchestrator/queue-tools';
import type { TaskHandoff } from '../../sequence/orchestrator/queue';
import { NotNeededReason } from '../../sequence/orchestrator/queue';

function text(s: string): {
content: [{ type: 'text'; text: string }];
Expand Down Expand Up @@ -60,6 +62,32 @@ export const PI_HANDOFF_PARAM_KEYS: readonly string[] = Object.keys(
HANDOFF_PARAMS.properties,
);

/** Mirrors the zod `COMPLETE_SHAPE`; ctx-independent, so it lives out here. */
const COMPLETE_PARAMS = Type.Object({
status: Type.Union([
Type.Literal('done'),
Type.Literal('failed'),
Type.Literal('not needed'),
]),
handoff: HANDOFF_PARAMS,
remark: Type.Optional(Type.String({ description: REMARK_ASK })),
notNeededReason: Type.Optional(
Type.Union(
[
Type.Literal(NotNeededReason.NotApplicable),
Type.Literal(NotNeededReason.UserDeclined),
Type.Literal(NotNeededReason.Blocked),
],
{ description: NOT_NEEDED_REASON_ASK },
),
),
});

/** Exported so the parity test can compare both harnesses' field sets. */
export const PI_COMPLETE_PARAM_KEYS: readonly string[] = Object.keys(
COMPLETE_PARAMS.properties,
);

/** The three queue tools bound to one agent's orchestrator context. */
export function createPiOrchestratorTools(
ctx: OrchestratorToolsContext,
Expand Down Expand Up @@ -114,24 +142,9 @@ export function createPiOrchestratorTools(
"Report the outcome of your task. Always call this exactly once when you finish, with a structured handoff for the next agent. Use status 'not needed' when the task does not apply to this project and you cannot do it (say why in the handoff) — not 'done'.",
promptSnippet:
'complete_task(status, handoff) — report your outcome exactly once when done',
parameters: Type.Object({
status: Type.Union([
Type.Literal('done'),
Type.Literal('failed'),
Type.Literal('not needed'),
]),
handoff: HANDOFF_PARAMS,
remark: Type.Optional(Type.String({ description: REMARK_ASK })),
}),
parameters: COMPLETE_PARAMS,
execute(_id, args) {
const res = applyComplete(
ctx,
args as {
status: 'done' | 'failed' | 'not needed';
handoff: TaskHandoff;
remark?: string;
},
);
const res = applyComplete(ctx, args as CompleteArgs);
if (!res.ok) return Promise.resolve(text(`Error: ${res.message}`));
return Promise.resolve(text('ok'));
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import * as os from 'os';
import * as path from 'path';
import { z } from 'zod';
import { analytics } from '@utils/analytics';
import { QueueStore } from '@lib/agent/runner/sequence/orchestrator/queue';
import {
NotNeededReason,
QueueStore,
SkipReason,
} from '@lib/agent/runner/sequence/orchestrator/queue';

vi.mock('@utils/analytics', () => ({
analytics: { wizardCapture: vi.fn() },
Expand All @@ -14,9 +18,12 @@ import {
applyReadHandoffs,
buildOrchestratorTools,
checkEnqueueGuards,
COMPLETE_SHAPE_KEYS,
ENQUEUE_MODEL_DESCRIPTION,
NOT_NEEDED_REASON_ASK,
type OrchestratorToolsContext,
} from '@lib/agent/runner/sequence/orchestrator/queue-tools';
import { PI_COMPLETE_PARAM_KEYS } from '@lib/agent/runner/harness/pi/orchestrator-tools';
import {
isValidModel,
VALID_MODELS,
Expand Down Expand Up @@ -230,6 +237,76 @@ describe('apply functions', () => {
});
});

/**
* `complete_task`'s `notNeededReason`. The handoff carries the agent's prose
* and this carries the machine-readable outcome, because the handoff on this
* step reaches live credentials and never reaches telemetry.
*/
describe('complete_task not-needed reasons', () => {
let dir: string;
let store: QueueStore;
let ctx: OrchestratorToolsContext;
const HANDOFF = { goals: 'g', did: 'd', forNextAgent: 'n' };

beforeEach(() => {
dir = tmpDir();
store = new QueueStore(dir, 'run-1');
ctx = { store, validTypes: VALID };
});

afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));

function skipWith(notNeededReason?: unknown) {
const t = store.enqueue({ type: 'install' });
store.start(t.id);
ctx.currentTaskId = t.id;
applyComplete(ctx, {
status: 'not needed',
handoff: HANDOFF,
notNeededReason,
} as never);
return store.get(t.id);
}

it.each([
NotNeededReason.NotApplicable,
NotNeededReason.UserDeclined,
NotNeededReason.Blocked,
])('forwards %s onto the task', (reason) => {
const t = skipWith(reason);
expect(t?.skipReason).toBe(SkipReason.AgentNotNeeded);
expect(t?.notNeededReason).toBe(reason);
});

// The pi harness hands tool arguments over unvalidated, and an agent asked
// for a reason readily writes a sentence. A sentence about this step can name
// a database or a key, so it must not become an analytics dimension.
it('drops a value that is not one of the declared reasons', () => {
const t = skipWith('the user cancelled the credential prompt');
expect(t?.skipReason).toBe(SkipReason.AgentNotNeeded);
expect(t?.notNeededReason).toBeUndefined();
});

it('skips as before when the agent declares no reason', () => {
const t = skipWith(undefined);
expect(t?.skipReason).toBe(SkipReason.AgentNotNeeded);
expect(t?.notNeededReason).toBeUndefined();
});

it('asks for every reason the type declares', () => {
for (const reason of Object.values(NotNeededReason)) {
expect(NOT_NEEDED_REASON_ASK).toContain(reason);
}
});

it('offers the field on both harnesses, and pi is the one that runs', () => {
expect(PI_COMPLETE_PARAM_KEYS).toContain('notNeededReason');
expect(PI_COMPLETE_PARAM_KEYS.slice().sort()).toEqual(
COMPLETE_SHAPE_KEYS.slice().sort(),
);
});
});

/**
* The `invalid-model` guard rejects any model outside the allow-list, so an
* agent that cannot see the list has to trip the guard to learn it. The
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
isNotNeededReason,
NotNeededReason,
QueueStore,
QUEUE_DIR_NAME,
SkipReason,
Expand Down Expand Up @@ -472,3 +474,82 @@ describe('skip reasons', () => {
);
});
});

/**
* The second dimension of an agent-reported skip.
*
* `agent-not-needed` names the agent as the decider, not what it decided, and
* `complete_task` offers `not needed` both for "the step does not apply" and
* for "you cannot do it". On the one step that stops to ask for credentials
* those are opposite outcomes, so the agent's own answer is recorded next to
* the reason rather than folded into it.
*/
describe('not-needed reasons', () => {
let dir: string;
let q: QueueStore;

beforeEach(() => {
dir = tmpDir();
q = new QueueStore(dir, 'run-1');
});

afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));

it.each([
NotNeededReason.NotApplicable,
NotNeededReason.UserDeclined,
NotNeededReason.Blocked,
])('records %s alongside the skip reason', (reason) => {
const t = q.enqueue({ type: 'warehouse' });
q.start(t.id);
q.skip(t.id, SkipReason.AgentNotNeeded, undefined, reason);

expect(q.get(t.id)?.skipReason).toBe(SkipReason.AgentNotNeeded);
expect(q.get(t.id)?.notNeededReason).toBe(reason);
});

it('stays unset on a skip that declares none', () => {
const t = q.enqueue({ type: 'warehouse' });
q.start(t.id);
q.skip(t.id, SkipReason.UserDeclined);

expect(q.get(t.id)?.skipReason).toBe(SkipReason.UserDeclined);
expect(q.get(t.id)?.notNeededReason).toBeUndefined();
});

it('hands it to the transition listener, so the skip event carries it', () => {
const seen: { reason?: string; notNeeded?: string }[] = [];
const listened = new QueueStore(dir, 'run-1', {
onTransition: (event: TransitionEvent, task: QueuedTask) => {
if (event === 'skip') {
seen.push({
reason: task.skipReason,
notNeeded: task.notNeededReason,
});
}
},
});

const t = listened.enqueue({ type: 'warehouse' });
listened.start(t.id);
listened.skip(
t.id,
SkipReason.AgentNotNeeded,
undefined,
NotNeededReason.UserDeclined,
);

expect(seen.at(-1)).toEqual({
reason: 'agent-not-needed',
notNeeded: 'user-declined',
});
});

it('only names outcomes the type declares', () => {
expect(isNotNeededReason('user-declined')).toBe(true);
expect(isNotNeededReason('the user cancelled the postgres prompt')).toBe(
false,
);
expect(isNotNeededReason(undefined)).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,11 @@ export async function runOrchestrator(
// did not apply were one number — which is how a regression that
// halved the warehouse completion rate stayed invisible for a week.
reason: task.skipReason,
// Also additive, and only ever set alongside `agent-not-needed`:
// that reason names the agent as the decider without saying what it
// decided, so a step the user withheld a credential for counted as
// a step that did not apply to the project.
not_needed_reason: task.notNeededReason,
});
break;
case 'fail':
Expand Down
Loading
Loading