@mjasnikovs/pi-task 0.18.26 → 0.18.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/config.d.ts +11 -7
- package/dist/config/register.js +3 -1
- package/dist/shared/child-process.d.ts +8 -0
- package/dist/shared/child-process.js +4 -2
- package/dist/shared/command-watchdog.d.ts +95 -0
- package/dist/shared/command-watchdog.js +150 -0
- package/dist/task/api-synthesis.d.ts +48 -0
- package/dist/task/api-synthesis.js +155 -0
- package/dist/task/command-watchdog.d.ts +9 -43
- package/dist/task/command-watchdog.js +28 -72
- package/dist/task/enforce-guidelines.d.ts +4 -0
- package/dist/task/enforce-guidelines.js +9 -0
- package/dist/task/gate-deps.js +14 -0
- package/dist/task/phases.js +85 -13
- package/dist/task/prompts.js +3 -0
- package/dist/task/verify-quality.d.ts +26 -0
- package/dist/task/verify-quality.js +182 -0
- package/dist/workers/pi-worker-core.d.ts +55 -0
- package/dist/workers/pi-worker-core.js +133 -4
- package/package.json +1 -1
package/dist/config/config.d.ts
CHANGED
|
@@ -43,16 +43,20 @@ export interface PiTaskConfig {
|
|
|
43
43
|
*/
|
|
44
44
|
extensionWhitelist: string[];
|
|
45
45
|
/**
|
|
46
|
-
* Wall-clock ceiling (ms) on a SINGLE tool execution
|
|
47
|
-
*
|
|
48
|
-
* own timeout. Local models routinely run a command that never returns
|
|
46
|
+
* Wall-clock ceiling (ms) on a SINGLE tool execution before the command
|
|
47
|
+
* watchdog steps in. Local models routinely run a command that never returns
|
|
49
48
|
* (e.g. `godot --headless` with no timeout, a dev server, a hung test) and
|
|
50
49
|
* the run wedges until the user manually aborts. pi's bash tool has an
|
|
51
50
|
* OPTIONAL timeout with NO default (bash.js), so a command the model didn't
|
|
52
|
-
* bound runs forever; this is the missing default
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
51
|
+
* bound runs forever; this is the missing default.
|
|
52
|
+
*
|
|
53
|
+
* ONE knob, TWO surfaces (shared/command-watchdog.ts): in the MAIN session
|
|
54
|
+
* the overrun call is cancelled via ctx.abort() (kills the tool's whole
|
|
55
|
+
* process tree) plus an auto-reminder turn; in the verify/fix GATE children
|
|
56
|
+
* (gate-deps.ts) the child is killed and re-spawned with a hint, the ceiling
|
|
57
|
+
* halving on repeat hangs. 0 = off — which unguards BOTH surfaces, gates
|
|
58
|
+
* included. Tool-agnostic: it arms on any tool execution, though in practice
|
|
59
|
+
* only bash runs long enough to trip it.
|
|
56
60
|
* DEFAULT 15 min: long enough for a real build/test suite, short enough that
|
|
57
61
|
* a true hang doesn't cost half an hour of dead time.
|
|
58
62
|
*/
|
package/dist/config/register.js
CHANGED
|
@@ -102,7 +102,9 @@ const ITEMS = [
|
|
|
102
102
|
description: 'Cancel a single command that runs longer than this and remind the model to set '
|
|
103
103
|
+ 'its own timeout. Catches a local model that runs a command which never returns '
|
|
104
104
|
+ '(hung build, dev server, no-timeout check) so the run stops itself instead of '
|
|
105
|
-
+ 'waiting for a manual abort.
|
|
105
|
+
+ 'waiting for a manual abort. One knob for both surfaces: the main session AND '
|
|
106
|
+
+ 'the verify/fix gate children. off disables it everywhere — gates can then hang '
|
|
107
|
+
+ 'unbounded',
|
|
106
108
|
// Display human labels; the stored config value stays the ms number.
|
|
107
109
|
values: COMMAND_TIMEOUT_OPTIONS.map(o => o.label)
|
|
108
110
|
}
|
|
@@ -58,6 +58,13 @@ export interface ChildResult {
|
|
|
58
58
|
export interface ToolCall {
|
|
59
59
|
name: string;
|
|
60
60
|
args: unknown;
|
|
61
|
+
/**
|
|
62
|
+
* pi's id for this tool call, carried on both `tool_execution_start` and
|
|
63
|
+
* `tool_execution_end` so a caller can pair them (the command watchdog arms
|
|
64
|
+
* on start and disarms on the matching end). Optional because the loop
|
|
65
|
+
* detector — the other consumer — keys on name+args and never needs it.
|
|
66
|
+
*/
|
|
67
|
+
toolCallId?: string;
|
|
61
68
|
}
|
|
62
69
|
export interface LoopHit {
|
|
63
70
|
call: ToolCall;
|
|
@@ -100,6 +107,7 @@ export interface RunChildJsonEventsOptions {
|
|
|
100
107
|
name: string;
|
|
101
108
|
isError: boolean;
|
|
102
109
|
text: string;
|
|
110
|
+
toolCallId?: string;
|
|
103
111
|
}) => void;
|
|
104
112
|
onFirstByte?: () => void;
|
|
105
113
|
/**
|
|
@@ -150,12 +150,13 @@ export class JsonEventSink {
|
|
|
150
150
|
}
|
|
151
151
|
if (t === 'tool_execution_start') {
|
|
152
152
|
const tn = typeof evt.toolName === 'string' ? evt.toolName : 'tool';
|
|
153
|
+
const id = typeof evt.toolCallId === 'string' ? evt.toolCallId : undefined;
|
|
153
154
|
if (opts.onLine) {
|
|
154
155
|
const detail = summarizeToolArgs(tn, evt.args);
|
|
155
156
|
opts.onLine(detail ? `${tn}: ${detail}` : tn);
|
|
156
157
|
}
|
|
157
158
|
if (opts.onToolCall) {
|
|
158
|
-
const hit = opts.onToolCall({ name: tn, args: evt.args });
|
|
159
|
+
const hit = opts.onToolCall({ name: tn, args: evt.args, toolCallId: id });
|
|
159
160
|
if (hit)
|
|
160
161
|
this.onLoopKill();
|
|
161
162
|
}
|
|
@@ -163,9 +164,10 @@ export class JsonEventSink {
|
|
|
163
164
|
}
|
|
164
165
|
if (t === 'tool_execution_end' && opts.onToolResult) {
|
|
165
166
|
const tn = typeof evt.toolName === 'string' ? evt.toolName : 'tool';
|
|
167
|
+
const id = typeof evt.toolCallId === 'string' ? evt.toolCallId : undefined;
|
|
166
168
|
const res = evt.result;
|
|
167
169
|
const text = toolResultText(res?.content);
|
|
168
|
-
opts.onToolResult({ name: tn, isError: evt.isError === true, text });
|
|
170
|
+
opts.onToolResult({ name: tn, isError: evt.isError === true, text, toolCallId: id });
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
173
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command watchdog — the per-tool-call wall-clock machine, shared by both
|
|
3
|
+
* surfaces that can run a command which never returns.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS LIVES IN shared/: pi's bash tool takes an OPTIONAL `timeout` with NO
|
|
6
|
+
* default (pi-coding-agent core/tools/bash.js), so ANY command the model didn't
|
|
7
|
+
* bound runs forever. That is true in two places, and they are disjoint:
|
|
8
|
+
*
|
|
9
|
+
* MAIN SESSION — the implementation turn, handed off via sendUserMessage
|
|
10
|
+
* (task/orchestrator.ts). Guarded by registerCommandWatchdog
|
|
11
|
+
* in task/command-watchdog.ts.
|
|
12
|
+
* CHILD pi — the verify / lint-fix / recommend / final-fix gate children
|
|
13
|
+
* (task/gate-deps.ts). Children are spawned `--no-extensions`
|
|
14
|
+
* (CHILD_BASE_ARGS), so the host's extension-event watchdog
|
|
15
|
+
* cannot see them at all. Guarded inside runWorker.
|
|
16
|
+
*
|
|
17
|
+
* Neither registration can cover the other's surface, so both exist — but the
|
|
18
|
+
* TIMER STATE MACHINE is identical, and lives here once. What differs is only
|
|
19
|
+
* the `onFire` side effect, which each adapter supplies:
|
|
20
|
+
*
|
|
21
|
+
* main session — ctx.abort() cancels just that tool call and the session
|
|
22
|
+
* survives to receive a follow-up reminder turn.
|
|
23
|
+
* child — there is no per-tool cancellation channel into a child, so
|
|
24
|
+
* the whole child is killed and re-spawned with
|
|
25
|
+
* {@link commandTimeoutHint} prepended. Coarser by necessity:
|
|
26
|
+
* the child's accumulated context is lost.
|
|
27
|
+
*/
|
|
28
|
+
/** Opaque timer handle — a real `setTimeout` return in production, anything the
|
|
29
|
+
* test's fake scheduler hands back under test. */
|
|
30
|
+
export type TimerHandle = unknown;
|
|
31
|
+
export interface WatchdogDeps {
|
|
32
|
+
/**
|
|
33
|
+
* The ceiling in ms, read PER command-start. How live that read is depends
|
|
34
|
+
* on the adapter: the main session passes a config read, so a /task-config
|
|
35
|
+
* change takes effect on the next command with no reload; the child adapter
|
|
36
|
+
* passes a constant frozen per attempt (the halved ceiling), so there a
|
|
37
|
+
* config change lands at the next attempt/gate, not the next command.
|
|
38
|
+
* 0 (or any non-positive value) means the watchdog is off and never arms.
|
|
39
|
+
*/
|
|
40
|
+
getTimeoutMs: () => number;
|
|
41
|
+
schedule: (fn: () => void, ms: number) => TimerHandle;
|
|
42
|
+
cancel: (handle: TimerHandle) => void;
|
|
43
|
+
/** Invoked when a command overruns: each adapter aborts/kills here. */
|
|
44
|
+
onFire: (toolCallId: string, toolName: string, timeoutMs: number) => void;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* MAIN-SESSION reminder, delivered as a follow-up turn after ctx.abort() has
|
|
48
|
+
* cancelled the offending tool call. The session is still alive and remembers
|
|
49
|
+
* the call, so this addresses it in the second person, present tense.
|
|
50
|
+
*/
|
|
51
|
+
export declare function reminderMessage(toolName: string, timeoutMs: number): string;
|
|
52
|
+
/**
|
|
53
|
+
* CHILD restart hint, prepended to the prompt of a re-spawned gate child. The
|
|
54
|
+
* killed child is GONE — this one never saw the command — so it is framed as
|
|
55
|
+
* "your previous attempt" and names the command, which the fresh child would
|
|
56
|
+
* otherwise have no way to know it must avoid repeating unbounded.
|
|
57
|
+
*
|
|
58
|
+
* `editsMayPersist` — set for a write-capable child (edit/write tools, or bash,
|
|
59
|
+
* whose commands have side effects). Nothing reverts the working tree between
|
|
60
|
+
* attempts, so telling such a child its previous attempt was "discarded" is
|
|
61
|
+
* false: partial edits and command side effects survive the kill, and a fresh
|
|
62
|
+
* child that believes it starts clean may re-apply them or misread the tree.
|
|
63
|
+
* Only the CONVERSATION is gone; the hint must say so precisely.
|
|
64
|
+
*
|
|
65
|
+
* Replaces the generic worker-timeout hint for this case: that one blames
|
|
66
|
+
* "exploring too long", which is the wrong diagnosis for a hung command and
|
|
67
|
+
* never mentions the timeout parameter.
|
|
68
|
+
*/
|
|
69
|
+
export declare function commandTimeoutHint(toolName: string, timeoutMs: number, opts?: {
|
|
70
|
+
commandDetail?: string;
|
|
71
|
+
editsMayPersist?: boolean;
|
|
72
|
+
}): string;
|
|
73
|
+
export declare class CommandWatchdog {
|
|
74
|
+
private readonly deps;
|
|
75
|
+
/** Armed timers, keyed by the tool call they guard. Tool executions are
|
|
76
|
+
* sequential, so this holds at most one entry in normal operation, but the
|
|
77
|
+
* map keeps it correct even if pi ever overlaps two calls. */
|
|
78
|
+
private readonly active;
|
|
79
|
+
constructor(deps: WatchdogDeps);
|
|
80
|
+
/** Arm a timer for a starting tool. No-op when the watchdog is off. */
|
|
81
|
+
onStart(toolCallId: string, toolName: string): void;
|
|
82
|
+
/** Disarm the timer for a finished tool. */
|
|
83
|
+
onEnd(toolCallId: string): void;
|
|
84
|
+
/** Cancel every armed timer — a turn-end / session-shutdown / child-exit
|
|
85
|
+
* safety net so no stray timer can fire into a later, unrelated command. */
|
|
86
|
+
clearAll(): void;
|
|
87
|
+
private disarm;
|
|
88
|
+
private fire;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Real-clock schedule/cancel, unref'd so a pending watchdog timer can never
|
|
92
|
+
* itself keep the process alive on exit. Shared by both adapters; tests
|
|
93
|
+
* substitute a fake scheduler instead.
|
|
94
|
+
*/
|
|
95
|
+
export declare const realTimerDeps: Pick<WatchdogDeps, 'schedule' | 'cancel'>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command watchdog — the per-tool-call wall-clock machine, shared by both
|
|
3
|
+
* surfaces that can run a command which never returns.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS LIVES IN shared/: pi's bash tool takes an OPTIONAL `timeout` with NO
|
|
6
|
+
* default (pi-coding-agent core/tools/bash.js), so ANY command the model didn't
|
|
7
|
+
* bound runs forever. That is true in two places, and they are disjoint:
|
|
8
|
+
*
|
|
9
|
+
* MAIN SESSION — the implementation turn, handed off via sendUserMessage
|
|
10
|
+
* (task/orchestrator.ts). Guarded by registerCommandWatchdog
|
|
11
|
+
* in task/command-watchdog.ts.
|
|
12
|
+
* CHILD pi — the verify / lint-fix / recommend / final-fix gate children
|
|
13
|
+
* (task/gate-deps.ts). Children are spawned `--no-extensions`
|
|
14
|
+
* (CHILD_BASE_ARGS), so the host's extension-event watchdog
|
|
15
|
+
* cannot see them at all. Guarded inside runWorker.
|
|
16
|
+
*
|
|
17
|
+
* Neither registration can cover the other's surface, so both exist — but the
|
|
18
|
+
* TIMER STATE MACHINE is identical, and lives here once. What differs is only
|
|
19
|
+
* the `onFire` side effect, which each adapter supplies:
|
|
20
|
+
*
|
|
21
|
+
* main session — ctx.abort() cancels just that tool call and the session
|
|
22
|
+
* survives to receive a follow-up reminder turn.
|
|
23
|
+
* child — there is no per-tool cancellation channel into a child, so
|
|
24
|
+
* the whole child is killed and re-spawned with
|
|
25
|
+
* {@link commandTimeoutHint} prepended. Coarser by necessity:
|
|
26
|
+
* the child's accumulated context is lost.
|
|
27
|
+
*/
|
|
28
|
+
/** Whole minutes, floored at 1, for the human-facing ceiling in both messages. */
|
|
29
|
+
function minutes(timeoutMs) {
|
|
30
|
+
const mins = Math.max(1, Math.round(timeoutMs / 60_000));
|
|
31
|
+
return `${mins} minute${mins === 1 ? '' : 's'}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The core correction, shared by both adapters' messages. Two jobs, both
|
|
35
|
+
* learned from live runs: stop the model reporting a killed command as a
|
|
36
|
+
* success, and name the ONE mechanism that prevents a repeat (the bash tool's
|
|
37
|
+
* own `timeout` parameter) rather than leaving "be faster" as the takeaway.
|
|
38
|
+
*/
|
|
39
|
+
function correction() {
|
|
40
|
+
return (`The command was killed before it finished and produced NO result, so do not `
|
|
41
|
+
+ `report it as completed or successful, and do not claim that anything it would `
|
|
42
|
+
+ `have started (a server, build, or process) is now running. `
|
|
43
|
+
+ `If it was a genuinely long-running command, you MUST re-run it with an explicit `
|
|
44
|
+
+ `timeout — set the bash tool's \`timeout\` parameter (in seconds) so it cannot hang `
|
|
45
|
+
+ `again — or break it into smaller steps. Do NOT simply retry the same unbounded command.`);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* MAIN-SESSION reminder, delivered as a follow-up turn after ctx.abort() has
|
|
49
|
+
* cancelled the offending tool call. The session is still alive and remembers
|
|
50
|
+
* the call, so this addresses it in the second person, present tense.
|
|
51
|
+
*/
|
|
52
|
+
export function reminderMessage(toolName, timeoutMs) {
|
|
53
|
+
return (`[SYSTEM] Your \`${toolName}\` call ran longer than ${minutes(timeoutMs)} `
|
|
54
|
+
+ `and was automatically cancelled — it looked stuck. `
|
|
55
|
+
+ correction());
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* CHILD restart hint, prepended to the prompt of a re-spawned gate child. The
|
|
59
|
+
* killed child is GONE — this one never saw the command — so it is framed as
|
|
60
|
+
* "your previous attempt" and names the command, which the fresh child would
|
|
61
|
+
* otherwise have no way to know it must avoid repeating unbounded.
|
|
62
|
+
*
|
|
63
|
+
* `editsMayPersist` — set for a write-capable child (edit/write tools, or bash,
|
|
64
|
+
* whose commands have side effects). Nothing reverts the working tree between
|
|
65
|
+
* attempts, so telling such a child its previous attempt was "discarded" is
|
|
66
|
+
* false: partial edits and command side effects survive the kill, and a fresh
|
|
67
|
+
* child that believes it starts clean may re-apply them or misread the tree.
|
|
68
|
+
* Only the CONVERSATION is gone; the hint must say so precisely.
|
|
69
|
+
*
|
|
70
|
+
* Replaces the generic worker-timeout hint for this case: that one blames
|
|
71
|
+
* "exploring too long", which is the wrong diagnosis for a hung command and
|
|
72
|
+
* never mentions the timeout parameter.
|
|
73
|
+
*/
|
|
74
|
+
export function commandTimeoutHint(toolName, timeoutMs, opts) {
|
|
75
|
+
const what = opts?.commandDetail ? ` (${opts.commandDetail})` : '';
|
|
76
|
+
const aftermath = opts?.editsMayPersist ?
|
|
77
|
+
`killed and that attempt's conversation was discarded — you are starting over, `
|
|
78
|
+
+ `BUT any file edits or command side effects it made before the kill are still `
|
|
79
|
+
+ `in the working tree. Check the current state of files before assuming they `
|
|
80
|
+
+ `are untouched or re-applying changes. `
|
|
81
|
+
: `killed and that attempt was discarded — you are seeing this task again from `
|
|
82
|
+
+ `the start. `;
|
|
83
|
+
return (`[SYSTEM NOTE: Your previous attempt ran a \`${toolName}\` command${what} that had not `
|
|
84
|
+
+ `returned after ${minutes(timeoutMs)}, so it was `
|
|
85
|
+
+ aftermath
|
|
86
|
+
+ correction()
|
|
87
|
+
+ `]`);
|
|
88
|
+
}
|
|
89
|
+
export class CommandWatchdog {
|
|
90
|
+
deps;
|
|
91
|
+
/** Armed timers, keyed by the tool call they guard. Tool executions are
|
|
92
|
+
* sequential, so this holds at most one entry in normal operation, but the
|
|
93
|
+
* map keeps it correct even if pi ever overlaps two calls. */
|
|
94
|
+
active = new Map();
|
|
95
|
+
constructor(deps) {
|
|
96
|
+
this.deps = deps;
|
|
97
|
+
}
|
|
98
|
+
/** Arm a timer for a starting tool. No-op when the watchdog is off. */
|
|
99
|
+
onStart(toolCallId, toolName) {
|
|
100
|
+
const ms = this.deps.getTimeoutMs();
|
|
101
|
+
if (!(ms > 0))
|
|
102
|
+
return;
|
|
103
|
+
// A duplicate start for the same id must not leak the previous timer.
|
|
104
|
+
this.disarm(toolCallId);
|
|
105
|
+
const handle = this.deps.schedule(() => this.fire(toolCallId, toolName, ms), ms);
|
|
106
|
+
this.active.set(toolCallId, handle);
|
|
107
|
+
}
|
|
108
|
+
/** Disarm the timer for a finished tool. */
|
|
109
|
+
onEnd(toolCallId) {
|
|
110
|
+
this.disarm(toolCallId);
|
|
111
|
+
}
|
|
112
|
+
/** Cancel every armed timer — a turn-end / session-shutdown / child-exit
|
|
113
|
+
* safety net so no stray timer can fire into a later, unrelated command. */
|
|
114
|
+
clearAll() {
|
|
115
|
+
for (const handle of this.active.values())
|
|
116
|
+
this.deps.cancel(handle);
|
|
117
|
+
this.active.clear();
|
|
118
|
+
}
|
|
119
|
+
disarm(toolCallId) {
|
|
120
|
+
const handle = this.active.get(toolCallId);
|
|
121
|
+
if (handle !== undefined) {
|
|
122
|
+
this.deps.cancel(handle);
|
|
123
|
+
this.active.delete(toolCallId);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
fire(toolCallId, toolName, ms) {
|
|
127
|
+
// If the tool ended in the same tick the timer fired, its entry is gone
|
|
128
|
+
// already — never abort a command that has just finished cleanly.
|
|
129
|
+
if (!this.active.has(toolCallId))
|
|
130
|
+
return;
|
|
131
|
+
this.active.delete(toolCallId);
|
|
132
|
+
this.deps.onFire(toolCallId, toolName, ms);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Real-clock schedule/cancel, unref'd so a pending watchdog timer can never
|
|
137
|
+
* itself keep the process alive on exit. Shared by both adapters; tests
|
|
138
|
+
* substitute a fake scheduler instead.
|
|
139
|
+
*/
|
|
140
|
+
export const realTimerDeps = {
|
|
141
|
+
schedule: (fn, ms) => {
|
|
142
|
+
const handle = setTimeout(fn, ms);
|
|
143
|
+
if (typeof handle.unref === 'function') {
|
|
144
|
+
;
|
|
145
|
+
handle.unref();
|
|
146
|
+
}
|
|
147
|
+
return handle;
|
|
148
|
+
},
|
|
149
|
+
cancel: handle => clearTimeout(handle)
|
|
150
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anti-synthesis guard for grill/clarify auto-answers (mx5 run-13 Bug A).
|
|
3
|
+
*
|
|
4
|
+
* The grill auto-answer channel invented `Bun.mkdirSync` (does not exist) while
|
|
5
|
+
* the task's own research APIS section carried the correct list (Bun.build,
|
|
6
|
+
* Bun.spawn). Nothing cross-checked the answer against it, so the invention was
|
|
7
|
+
* promoted into the task's title, requirements, acceptance criteria AND VERIFY
|
|
8
|
+
* block, and the implementer shipped a fake ambient declare to compile it.
|
|
9
|
+
*
|
|
10
|
+
* Lever: the same verbatim-substring anti-synthesis check as the F3 contract
|
|
11
|
+
* registry. Extract API-shaped identifiers (`Namespace.member`) from the answer;
|
|
12
|
+
* an identifier is SYNTHESIZED when
|
|
13
|
+
* (a) the full identifier appears nowhere in the research (APIS/docs/context
|
|
14
|
+
* sections, verbatim substring, case-sensitive) and nowhere in the
|
|
15
|
+
* question itself, AND
|
|
16
|
+
* (b) the research DOES mention that namespace's API surface (`Bun.` appears
|
|
17
|
+
* somewhere) — i.e. research claims coverage of the namespace, so a
|
|
18
|
+
* member absent from it is suspicious rather than merely uncovered.
|
|
19
|
+
* Gate (b) is the step-aside rule: when research never mentions the namespace
|
|
20
|
+
* at all (React.StrictMode in a task whose research covered no React API), the
|
|
21
|
+
* check is INCONCLUSIVE and must not fire — the guard may only cost time,
|
|
22
|
+
* never work. Same for the clarify-triage seam, whose research slot is a stub:
|
|
23
|
+
* no namespace coverage ⇒ no findings ⇒ guard inert by construction.
|
|
24
|
+
*
|
|
25
|
+
* Caller contract (phaseAutoAnswer): findings ⇒ re-ask ONCE with the research
|
|
26
|
+
* API lines injected (belt); a re-asked answer that still carries a flagged
|
|
27
|
+
* identifier is surfaced to the user as UNKNOWN instead of being promoted.
|
|
28
|
+
*/
|
|
29
|
+
export interface SynthesizedApiFinding {
|
|
30
|
+
/** The full flagged identifier, e.g. "Bun.mkdirSync". */
|
|
31
|
+
identifier: string;
|
|
32
|
+
/** Its namespace, e.g. "Bun" — research mentions `Bun.` but not this member. */
|
|
33
|
+
namespace: string;
|
|
34
|
+
}
|
|
35
|
+
/** All API-shaped identifiers in a text, deduped, first-seen order. */
|
|
36
|
+
export declare function extractApiIdentifiers(text: string): string[];
|
|
37
|
+
/**
|
|
38
|
+
* The synthesized identifiers in an auto-answer: API-shaped, absent from the
|
|
39
|
+
* research and the question, in a namespace the research claims to cover.
|
|
40
|
+
* Verbatim-substring membership — never a model judgement.
|
|
41
|
+
*/
|
|
42
|
+
export declare function findSynthesizedApis(answer: string, question: string, research: string): SynthesizedApiFinding[];
|
|
43
|
+
/**
|
|
44
|
+
* Re-ask hint (SYSTEM NOTE shape, mirrors GRILL_AUTO_FORMAT_HINT): names the
|
|
45
|
+
* unverified identifiers, injects the research lines that ARE verified for
|
|
46
|
+
* those namespaces, and demands an answer grounded in them — or UNKNOWN.
|
|
47
|
+
*/
|
|
48
|
+
export declare function synthesizedApiReaskHint(findings: SynthesizedApiFinding[], research: string): string;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anti-synthesis guard for grill/clarify auto-answers (mx5 run-13 Bug A).
|
|
3
|
+
*
|
|
4
|
+
* The grill auto-answer channel invented `Bun.mkdirSync` (does not exist) while
|
|
5
|
+
* the task's own research APIS section carried the correct list (Bun.build,
|
|
6
|
+
* Bun.spawn). Nothing cross-checked the answer against it, so the invention was
|
|
7
|
+
* promoted into the task's title, requirements, acceptance criteria AND VERIFY
|
|
8
|
+
* block, and the implementer shipped a fake ambient declare to compile it.
|
|
9
|
+
*
|
|
10
|
+
* Lever: the same verbatim-substring anti-synthesis check as the F3 contract
|
|
11
|
+
* registry. Extract API-shaped identifiers (`Namespace.member`) from the answer;
|
|
12
|
+
* an identifier is SYNTHESIZED when
|
|
13
|
+
* (a) the full identifier appears nowhere in the research (APIS/docs/context
|
|
14
|
+
* sections, verbatim substring, case-sensitive) and nowhere in the
|
|
15
|
+
* question itself, AND
|
|
16
|
+
* (b) the research DOES mention that namespace's API surface (`Bun.` appears
|
|
17
|
+
* somewhere) — i.e. research claims coverage of the namespace, so a
|
|
18
|
+
* member absent from it is suspicious rather than merely uncovered.
|
|
19
|
+
* Gate (b) is the step-aside rule: when research never mentions the namespace
|
|
20
|
+
* at all (React.StrictMode in a task whose research covered no React API), the
|
|
21
|
+
* check is INCONCLUSIVE and must not fire — the guard may only cost time,
|
|
22
|
+
* never work. Same for the clarify-triage seam, whose research slot is a stub:
|
|
23
|
+
* no namespace coverage ⇒ no findings ⇒ guard inert by construction.
|
|
24
|
+
*
|
|
25
|
+
* Caller contract (phaseAutoAnswer): findings ⇒ re-ask ONCE with the research
|
|
26
|
+
* API lines injected (belt); a re-asked answer that still carries a flagged
|
|
27
|
+
* identifier is surfaced to the user as UNKNOWN instead of being promoted.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* `Namespace.member` where the namespace starts uppercase (Bun, React, Deno —
|
|
31
|
+
* the global/imported-namespace API shape; run-13's TP is exactly this) and
|
|
32
|
+
* both sides are ≥2 chars (kills "U.S.", "e.G" prose shapes). Member may start
|
|
33
|
+
* either case: `Bun.mkdirSync` and `React.StrictMode` are both API-shaped.
|
|
34
|
+
*/
|
|
35
|
+
const API_IDENT_RE = /\b([A-Z][A-Za-z0-9_$]+)\.([A-Za-z_$][A-Za-z0-9_$]+)\b/g;
|
|
36
|
+
/**
|
|
37
|
+
* Member names that make the match a file name, domain, or version-ish token
|
|
38
|
+
* rather than an API (Node.js, App.tsx, README.md, Fly.io, Express.com). All
|
|
39
|
+
* lowercase-compared, so `INDEX.HTML` is excluded too.
|
|
40
|
+
*/
|
|
41
|
+
const NON_API_MEMBERS = new Set([
|
|
42
|
+
'js',
|
|
43
|
+
'ts',
|
|
44
|
+
'jsx',
|
|
45
|
+
'tsx',
|
|
46
|
+
'mjs',
|
|
47
|
+
'cjs',
|
|
48
|
+
'mts',
|
|
49
|
+
'cts',
|
|
50
|
+
'json',
|
|
51
|
+
'jsonc',
|
|
52
|
+
'md',
|
|
53
|
+
'html',
|
|
54
|
+
'htm',
|
|
55
|
+
'css',
|
|
56
|
+
'scss',
|
|
57
|
+
'less',
|
|
58
|
+
'svg',
|
|
59
|
+
'png',
|
|
60
|
+
'jpg',
|
|
61
|
+
'jpeg',
|
|
62
|
+
'gif',
|
|
63
|
+
'ico',
|
|
64
|
+
'txt',
|
|
65
|
+
'yml',
|
|
66
|
+
'yaml',
|
|
67
|
+
'toml',
|
|
68
|
+
'lock',
|
|
69
|
+
'map',
|
|
70
|
+
'env',
|
|
71
|
+
'sh',
|
|
72
|
+
'sql',
|
|
73
|
+
'db',
|
|
74
|
+
'sqlite',
|
|
75
|
+
'wasm',
|
|
76
|
+
'node',
|
|
77
|
+
'exe',
|
|
78
|
+
'com',
|
|
79
|
+
'org',
|
|
80
|
+
'net',
|
|
81
|
+
'io',
|
|
82
|
+
'dev',
|
|
83
|
+
'app',
|
|
84
|
+
'ai',
|
|
85
|
+
'co',
|
|
86
|
+
'gg'
|
|
87
|
+
]);
|
|
88
|
+
/** All API-shaped identifiers in a text, deduped, first-seen order. */
|
|
89
|
+
export function extractApiIdentifiers(text) {
|
|
90
|
+
const out = [];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
for (const m of text.matchAll(API_IDENT_RE)) {
|
|
93
|
+
const [full, , member] = m;
|
|
94
|
+
if (NON_API_MEMBERS.has(member.toLowerCase()))
|
|
95
|
+
continue;
|
|
96
|
+
if (seen.has(full))
|
|
97
|
+
continue;
|
|
98
|
+
seen.add(full);
|
|
99
|
+
out.push(full);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The synthesized identifiers in an auto-answer: API-shaped, absent from the
|
|
105
|
+
* research and the question, in a namespace the research claims to cover.
|
|
106
|
+
* Verbatim-substring membership — never a model judgement.
|
|
107
|
+
*/
|
|
108
|
+
export function findSynthesizedApis(answer, question, research) {
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const identifier of extractApiIdentifiers(answer)) {
|
|
111
|
+
if (research.includes(identifier) || question.includes(identifier))
|
|
112
|
+
continue;
|
|
113
|
+
const namespace = identifier.slice(0, identifier.indexOf('.'));
|
|
114
|
+
// Step-aside gate: research must claim this namespace's API surface.
|
|
115
|
+
if (!research.includes(`${namespace}.`))
|
|
116
|
+
continue;
|
|
117
|
+
out.push({ identifier, namespace });
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** Research lines that mention any flagged namespace — the verified API list to inject. */
|
|
122
|
+
function verifiedApiLines(findings, research) {
|
|
123
|
+
const namespaces = new Set(findings.map(f => f.namespace));
|
|
124
|
+
const lines = [];
|
|
125
|
+
for (const line of research.split('\n')) {
|
|
126
|
+
const t = line.trim();
|
|
127
|
+
if (t.length === 0)
|
|
128
|
+
continue;
|
|
129
|
+
for (const ns of namespaces) {
|
|
130
|
+
if (t.includes(`${ns}.`)) {
|
|
131
|
+
lines.push(t);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return lines.slice(0, 20);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Re-ask hint (SYSTEM NOTE shape, mirrors GRILL_AUTO_FORMAT_HINT): names the
|
|
140
|
+
* unverified identifiers, injects the research lines that ARE verified for
|
|
141
|
+
* those namespaces, and demands an answer grounded in them — or UNKNOWN.
|
|
142
|
+
*/
|
|
143
|
+
export function synthesizedApiReaskHint(findings, research) {
|
|
144
|
+
const flagged = findings.map(f => `\`${f.identifier}\``).join(', ');
|
|
145
|
+
const verified = verifiedApiLines(findings, research);
|
|
146
|
+
return (`[SYSTEM NOTE: Your previous answer named ${flagged} — NOT present in this task's `
|
|
147
|
+
+ 'verified research API list, so it may not exist (a plausible-looking invented API '
|
|
148
|
+
+ 'poisons the whole task: it gets promoted into requirements and VERIFY, and the '
|
|
149
|
+
+ 'implementation fakes type declarations to compile it). The VERIFIED research lines '
|
|
150
|
+
+ 'for that namespace are:\n'
|
|
151
|
+
+ verified.map(l => ` ${l}`).join('\n')
|
|
152
|
+
+ '\nAnswer again using ONLY APIs from the research or the question. If the behavior '
|
|
153
|
+
+ 'needs an API the research does not list, do NOT invent one — describe the behavior '
|
|
154
|
+
+ 'without naming a concrete API, or tag UNKNOWN.]');
|
|
155
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* configured ceiling and reminds the model to bound its own commands.
|
|
3
|
+
* MAIN-SESSION adapter for the command watchdog.
|
|
5
4
|
*
|
|
6
5
|
* WHY: a local model in the MAIN session routinely runs a command that never
|
|
7
6
|
* returns — `godot --headless --check-only` with no timeout, a dev server, a
|
|
@@ -17,48 +16,15 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
17
16
|
* what happened so it retries with a timeout instead of hanging again.
|
|
18
17
|
*
|
|
19
18
|
* Tool-agnostic: it arms on ANY tool, honouring "any command can run forever",
|
|
20
|
-
* though in practice only bash runs long enough to trip it.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
export type TimerHandle = unknown;
|
|
28
|
-
export interface WatchdogDeps {
|
|
29
|
-
/**
|
|
30
|
-
* The ceiling in ms, read PER command-start so a /task-config change takes
|
|
31
|
-
* effect on the next command with no reload. 0 (or any non-positive value)
|
|
32
|
-
* means the watchdog is off and never arms.
|
|
33
|
-
*/
|
|
34
|
-
getTimeoutMs: () => number;
|
|
35
|
-
schedule: (fn: () => void, ms: number) => TimerHandle;
|
|
36
|
-
cancel: (handle: TimerHandle) => void;
|
|
37
|
-
/** Invoked when a command overruns: the registration aborts + reminds here. */
|
|
38
|
-
onFire: (toolCallId: string, toolName: string, timeoutMs: number) => void;
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* The reminder delivered to the model after its command is cancelled. Kept pure
|
|
42
|
-
* and exported so a test can assert its shape without driving the whole session.
|
|
19
|
+
* though in practice only bash runs long enough to trip it.
|
|
20
|
+
*
|
|
21
|
+
* SCOPE — this covers the main session ONLY, which is where the implementation
|
|
22
|
+
* turn runs (orchestrator hands the spec off via sendUserMessage). Gate
|
|
23
|
+
* children are spawned `--no-extensions`, so no host extension exists inside
|
|
24
|
+
* them; their equivalent guard lives in runWorker (workers/pi-worker-core.ts)
|
|
25
|
+
* and shares the same machine from shared/command-watchdog.ts.
|
|
43
26
|
*/
|
|
44
|
-
export
|
|
45
|
-
export declare class CommandWatchdog {
|
|
46
|
-
private readonly deps;
|
|
47
|
-
/** Armed timers, keyed by the tool call they guard. Tool executions are
|
|
48
|
-
* sequential, so this holds at most one entry in normal operation, but the
|
|
49
|
-
* map keeps it correct even if pi ever overlaps two calls. */
|
|
50
|
-
private readonly active;
|
|
51
|
-
constructor(deps: WatchdogDeps);
|
|
52
|
-
/** Arm a timer for a starting tool. No-op when the watchdog is off. */
|
|
53
|
-
onStart(toolCallId: string, toolName: string): void;
|
|
54
|
-
/** Disarm the timer for a finished tool. */
|
|
55
|
-
onEnd(toolCallId: string): void;
|
|
56
|
-
/** Cancel every armed timer — a turn-end / session-shutdown safety net so no
|
|
57
|
-
* stray timer can fire into a later, unrelated command. */
|
|
58
|
-
clearAll(): void;
|
|
59
|
-
private disarm;
|
|
60
|
-
private fire;
|
|
61
|
-
}
|
|
27
|
+
export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, type TimerHandle, type WatchdogDeps } from '../shared/command-watchdog.js';
|
|
62
28
|
/**
|
|
63
29
|
* Wire the watchdog into the main session. Only ever active in the host session
|
|
64
30
|
* (children run `--no-extensions`), which is exactly where the observed hangs
|