@prohost/cli 0.3.0 → 0.4.1
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/CHANGELOG.md +85 -0
- package/README.md +55 -14
- package/dist/agent/codex.d.ts +83 -2
- package/dist/agent/codex.js +103 -4
- package/dist/agent/command.d.ts +24 -0
- package/dist/agent/command.js +85 -18
- package/dist/agent/contract.d.ts +20 -7
- package/dist/agent/contract.js +18 -7
- package/dist/agent/daemon.d.ts +2 -0
- package/dist/agent/daemon.js +6 -0
- package/dist/agent/exec.d.ts +35 -0
- package/dist/agent/exec.js +100 -3
- package/dist/agent/prompt.d.ts +11 -0
- package/dist/agent/prompt.js +56 -19
- package/dist/agent/run.d.ts +39 -11
- package/dist/agent/run.js +116 -25
- package/dist/index.js +9 -5
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/agent/contract.d.ts
CHANGED
|
@@ -63,14 +63,19 @@ export declare function agentRunCompletePath(runId: string): string;
|
|
|
63
63
|
*/
|
|
64
64
|
export declare function agentRunHeartbeatPath(runId: string): string;
|
|
65
65
|
/**
|
|
66
|
-
* Where this run's heartbeats go.
|
|
66
|
+
* Where this run's heartbeats go, in descending order of how much we know.
|
|
67
67
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
68
|
+
* 1. The `heartbeat` block, when the payload carries one. A path the server
|
|
69
|
+
* stated is not a guess, so it is used verbatim.
|
|
70
|
+
* 2. Otherwise, derived from the server-supplied completion path: the two
|
|
71
|
+
* calls are the same resource, so a server that moves its agent-run routes
|
|
72
|
+
* moves both together and a CLI already on somebody's laptop follows
|
|
73
|
+
* without a release.
|
|
74
|
+
* 3. Otherwise the documented path, for a completion path shaped in some way
|
|
75
|
+
* we don't recognise.
|
|
76
|
+
*
|
|
77
|
+
* Steps 2 and 3 are exactly what shipped in 0.3.0 and are not going anywhere:
|
|
78
|
+
* the block is additive, and every server that predates it must keep working.
|
|
74
79
|
*/
|
|
75
80
|
export declare function heartbeatPathFor(run: AgentRunRequest): string;
|
|
76
81
|
/**
|
|
@@ -216,6 +221,14 @@ export interface AgentRunRequest {
|
|
|
216
221
|
reply_body_extra?: Record<string, unknown>;
|
|
217
222
|
/** Server-supplied path to complete the run at. */
|
|
218
223
|
completion_path: string;
|
|
224
|
+
/**
|
|
225
|
+
* Server-supplied path to heartbeat the run at.
|
|
226
|
+
*
|
|
227
|
+
* Absent on every server built before the `heartbeat` block was added
|
|
228
|
+
* alongside `completion`, which is most of them — see {@link heartbeatPathFor}
|
|
229
|
+
* for what happens then.
|
|
230
|
+
*/
|
|
231
|
+
heartbeat_path?: string;
|
|
219
232
|
/**
|
|
220
233
|
* Earlier messages on this surface, oldest first. Absent on a server that
|
|
221
234
|
* predates the field — which is the case the harness shipped with, so an
|
package/dist/agent/contract.js
CHANGED
|
@@ -69,16 +69,23 @@ export function agentRunHeartbeatPath(runId) {
|
|
|
69
69
|
return `/v1/agent-runs/${encodeURIComponent(runId)}/heartbeat`;
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
|
-
* Where this run's heartbeats go.
|
|
72
|
+
* Where this run's heartbeats go, in descending order of how much we know.
|
|
73
73
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
74
|
+
* 1. The `heartbeat` block, when the payload carries one. A path the server
|
|
75
|
+
* stated is not a guess, so it is used verbatim.
|
|
76
|
+
* 2. Otherwise, derived from the server-supplied completion path: the two
|
|
77
|
+
* calls are the same resource, so a server that moves its agent-run routes
|
|
78
|
+
* moves both together and a CLI already on somebody's laptop follows
|
|
79
|
+
* without a release.
|
|
80
|
+
* 3. Otherwise the documented path, for a completion path shaped in some way
|
|
81
|
+
* we don't recognise.
|
|
82
|
+
*
|
|
83
|
+
* Steps 2 and 3 are exactly what shipped in 0.3.0 and are not going anywhere:
|
|
84
|
+
* the block is additive, and every server that predates it must keep working.
|
|
80
85
|
*/
|
|
81
86
|
export function heartbeatPathFor(run) {
|
|
87
|
+
if (run.heartbeat_path)
|
|
88
|
+
return run.heartbeat_path;
|
|
82
89
|
const completion = run.completion_path;
|
|
83
90
|
const suffix = '/complete';
|
|
84
91
|
return completion.endsWith(suffix)
|
|
@@ -255,6 +262,7 @@ export function parseRunRequest(data) {
|
|
|
255
262
|
const trigger = obj(data.trigger);
|
|
256
263
|
const replyInstructions = obj(data.reply_instructions);
|
|
257
264
|
const completion = obj(data.completion);
|
|
265
|
+
const heartbeat = obj(data.heartbeat);
|
|
258
266
|
const context = obj(data.context);
|
|
259
267
|
const declaredSurface = str(replyInstructions.surface);
|
|
260
268
|
const conversationId = str(data.conversation_id) ?? str(replyInstructions.conversation_id);
|
|
@@ -297,6 +305,9 @@ export function parseRunRequest(data) {
|
|
|
297
305
|
reply_body_key: str(replyInstructions.body_field) ?? legacyBodyKeyFor(surface),
|
|
298
306
|
reply_body_extra: bodyExtra(replyInstructions.body_extra),
|
|
299
307
|
completion_path: str(completion.path) ?? agentRunCompletePath(runId),
|
|
308
|
+
// No fallback of its own: an absent block is not an error, it is an older
|
|
309
|
+
// server, and `heartbeatPathFor` already knows what to do about that.
|
|
310
|
+
heartbeat_path: str(heartbeat.path),
|
|
300
311
|
// Accepted both at the top level and nested under `context`, because the
|
|
301
312
|
// server can move them without a CLI release either way — and a published
|
|
302
313
|
// CLI cannot be redeployed to follow.
|
package/dist/agent/daemon.d.ts
CHANGED
|
@@ -83,6 +83,8 @@ export interface HarnessInvocation {
|
|
|
83
83
|
exec: string;
|
|
84
84
|
workdir: string;
|
|
85
85
|
timeoutSeconds?: number;
|
|
86
|
+
/** Seconds of total silence before a run is treated as stalled. */
|
|
87
|
+
idleTimeoutSeconds?: number;
|
|
86
88
|
machine?: string;
|
|
87
89
|
url?: string;
|
|
88
90
|
allowUnverified?: boolean;
|
package/dist/agent/daemon.js
CHANGED
|
@@ -176,8 +176,14 @@ export function harnessArguments(invocation) {
|
|
|
176
176
|
'--workdir',
|
|
177
177
|
invocation.workdir,
|
|
178
178
|
];
|
|
179
|
+
// Compared against `undefined` rather than tested for truth: `0` is a real
|
|
180
|
+
// setting for both flags — no wall-clock cap, no watchdog — and dropping it
|
|
181
|
+
// would install a daemon quietly running the defaults instead.
|
|
179
182
|
if (invocation.timeoutSeconds !== undefined)
|
|
180
183
|
args.push('--timeout', String(invocation.timeoutSeconds));
|
|
184
|
+
if (invocation.idleTimeoutSeconds !== undefined) {
|
|
185
|
+
args.push('--idle-timeout', String(invocation.idleTimeoutSeconds));
|
|
186
|
+
}
|
|
181
187
|
if (invocation.machine)
|
|
182
188
|
args.push('--machine', invocation.machine);
|
|
183
189
|
if (invocation.url)
|
package/dist/agent/exec.d.ts
CHANGED
|
@@ -26,6 +26,19 @@ export interface ExecOptions {
|
|
|
26
26
|
* genuinely takes hours and a local clock is the wrong thing to bound it by.
|
|
27
27
|
*/
|
|
28
28
|
timeoutMs: number;
|
|
29
|
+
/**
|
|
30
|
+
* Kill the run when the child produces **nothing** for this long.
|
|
31
|
+
*
|
|
32
|
+
* The question a wall clock cannot answer: is this run working, or wedged?
|
|
33
|
+
* Bytes on stdout or stderr are the one signal every brain emits — stream
|
|
34
|
+
* events, a progress line, a log — so a window of total silence is the
|
|
35
|
+
* closest thing to evidence that a child has stopped making progress rather
|
|
36
|
+
* than merely taking a while. Every chunk on either stream resets it.
|
|
37
|
+
*
|
|
38
|
+
* ``0`` or unset disables it, at which point only {@link timeoutMs}, the
|
|
39
|
+
* {@link cancelSignal} and the server's lease can end the run.
|
|
40
|
+
*/
|
|
41
|
+
idleTimeoutMs?: number;
|
|
29
42
|
/** Working directory for the child. Defaults to the CLI's cwd. */
|
|
30
43
|
cwd?: string;
|
|
31
44
|
/** Extra environment for the child, merged over ``process.env``. */
|
|
@@ -55,7 +68,18 @@ export interface ExecResult {
|
|
|
55
68
|
stdout: string;
|
|
56
69
|
stderr: string;
|
|
57
70
|
exitCode: number | null;
|
|
71
|
+
/** The run overran {@link ExecOptions.timeoutMs}. */
|
|
58
72
|
timedOut: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* The run went silent for {@link ExecOptions.idleTimeoutMs} and was killed.
|
|
75
|
+
*
|
|
76
|
+
* Separate from {@link timedOut} because the two say different things to
|
|
77
|
+
* whoever reads the failure: one run used its whole budget, the other stopped
|
|
78
|
+
* doing anything at all. They are handled identically everywhere else — both
|
|
79
|
+
* are kills from outside, so both salvage partial output and keep the
|
|
80
|
+
* session they had recorded.
|
|
81
|
+
*/
|
|
82
|
+
idleTimedOut: boolean;
|
|
59
83
|
/** The run was stopped through {@link ExecOptions.cancelSignal}. */
|
|
60
84
|
cancelled: boolean;
|
|
61
85
|
/** Populated when the process could not be started at all. */
|
|
@@ -76,6 +100,17 @@ export declare const CANCELLED_ERROR = "cancelled";
|
|
|
76
100
|
* Kept to one line for the same reason.
|
|
77
101
|
*/
|
|
78
102
|
export declare function timeoutMessage(timeoutMs: number): string;
|
|
103
|
+
/**
|
|
104
|
+
* What a run killed by the inactivity watchdog reports.
|
|
105
|
+
*
|
|
106
|
+
* Same job as {@link timeoutMessage}, and the same constraint: the server
|
|
107
|
+
* echoes a failed run's error into the thread, so this is read by the person
|
|
108
|
+
* who was waiting rather than by whoever tails `agent.log`. It has to say what
|
|
109
|
+
* happened ("nothing came out of it"), that this is an inference rather than a
|
|
110
|
+
* crash the agent reported, and how to change the judgement — in one line,
|
|
111
|
+
* because the notice it lands in has room for one.
|
|
112
|
+
*/
|
|
113
|
+
export declare function idleTimeoutMessage(idleTimeoutMs: number): string;
|
|
79
114
|
/**
|
|
80
115
|
* Execute the agent command with ``prompt`` on stdin and return its stdout.
|
|
81
116
|
*
|
package/dist/agent/exec.js
CHANGED
|
@@ -30,6 +30,38 @@ export const CANCELLED_ERROR = 'cancelled';
|
|
|
30
30
|
export function timeoutMessage(timeoutMs) {
|
|
31
31
|
return `timed out after ${timeoutMs}ms — raise the ceiling with --timeout <seconds> (0 = no limit)`;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Say a silence window the way a person would say it.
|
|
35
|
+
*
|
|
36
|
+
* The default is fifteen minutes and it wants to read as "15 minutes", not
|
|
37
|
+
* "900000ms": this string is shown to whoever was waiting on the agent, and a
|
|
38
|
+
* number they have to convert is a number they will misread.
|
|
39
|
+
*/
|
|
40
|
+
function renderWindow(ms) {
|
|
41
|
+
if (ms >= 60_000 && ms % 60_000 === 0) {
|
|
42
|
+
const minutes = ms / 60_000;
|
|
43
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
44
|
+
}
|
|
45
|
+
const seconds = ms / 1_000;
|
|
46
|
+
const rendered = Number.isInteger(seconds)
|
|
47
|
+
? String(seconds)
|
|
48
|
+
: seconds.toFixed(seconds < 1 ? 2 : 1);
|
|
49
|
+
return `${rendered} second${rendered === '1' ? '' : 's'}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* What a run killed by the inactivity watchdog reports.
|
|
53
|
+
*
|
|
54
|
+
* Same job as {@link timeoutMessage}, and the same constraint: the server
|
|
55
|
+
* echoes a failed run's error into the thread, so this is read by the person
|
|
56
|
+
* who was waiting rather than by whoever tails `agent.log`. It has to say what
|
|
57
|
+
* happened ("nothing came out of it"), that this is an inference rather than a
|
|
58
|
+
* crash the agent reported, and how to change the judgement — in one line,
|
|
59
|
+
* because the notice it lands in has room for one.
|
|
60
|
+
*/
|
|
61
|
+
export function idleTimeoutMessage(idleTimeoutMs) {
|
|
62
|
+
return (`no output for ${renderWindow(idleTimeoutMs)} — treated as stalled; ` +
|
|
63
|
+
'tune with --idle-timeout <seconds> (0 = disabled)');
|
|
64
|
+
}
|
|
33
65
|
function clamp(text) {
|
|
34
66
|
if (text.length <= MAX_OUTPUT_CHARS)
|
|
35
67
|
return text;
|
|
@@ -49,6 +81,7 @@ export async function execAgent(options) {
|
|
|
49
81
|
let stdout = '';
|
|
50
82
|
let stderr = '';
|
|
51
83
|
let timedOut = false;
|
|
84
|
+
let idleTimedOut = false;
|
|
52
85
|
let cancelled = false;
|
|
53
86
|
let settled = false;
|
|
54
87
|
// Nothing to stop if the stop already happened: a run cancelled while it
|
|
@@ -60,6 +93,7 @@ export async function execAgent(options) {
|
|
|
60
93
|
stderr: '',
|
|
61
94
|
exitCode: null,
|
|
62
95
|
timedOut: false,
|
|
96
|
+
idleTimedOut: false,
|
|
63
97
|
cancelled: true,
|
|
64
98
|
error: CANCELLED_ERROR,
|
|
65
99
|
durationMs: Date.now() - started,
|
|
@@ -88,6 +122,7 @@ export async function execAgent(options) {
|
|
|
88
122
|
stderr: '',
|
|
89
123
|
exitCode: null,
|
|
90
124
|
timedOut: false,
|
|
125
|
+
idleTimedOut: false,
|
|
91
126
|
cancelled: false,
|
|
92
127
|
error: err instanceof Error ? err.message : String(err),
|
|
93
128
|
durationMs: Date.now() - started,
|
|
@@ -95,15 +130,32 @@ export async function execAgent(options) {
|
|
|
95
130
|
return;
|
|
96
131
|
}
|
|
97
132
|
let timer;
|
|
133
|
+
let idleTimer;
|
|
98
134
|
let killTimer;
|
|
99
|
-
|
|
100
|
-
|
|
135
|
+
const idleMs = options.idleTimeoutMs ?? 0;
|
|
136
|
+
/**
|
|
137
|
+
* Why the run ended, when it ended because we ended it.
|
|
138
|
+
*
|
|
139
|
+
* Ordered by which decision was made first in practice: a deliberate stop
|
|
140
|
+
* outranks either clock, and the wall clock outranks the watchdog because
|
|
141
|
+
* `terminate` disarms the watchdog on its way out — so if both flags are
|
|
142
|
+
* somehow set, the ceiling is what actually fired.
|
|
143
|
+
*/
|
|
144
|
+
const stopReason = () => cancelled
|
|
145
|
+
? CANCELLED_ERROR
|
|
146
|
+
: timedOut
|
|
147
|
+
? timeoutMessage(options.timeoutMs)
|
|
148
|
+
: idleTimedOut
|
|
149
|
+
? idleTimeoutMessage(idleMs)
|
|
150
|
+
: undefined;
|
|
101
151
|
const finish = (result) => {
|
|
102
152
|
if (settled)
|
|
103
153
|
return;
|
|
104
154
|
settled = true;
|
|
105
155
|
if (timer !== undefined)
|
|
106
156
|
clearTimeout(timer);
|
|
157
|
+
if (idleTimer !== undefined)
|
|
158
|
+
clearTimeout(idleTimer);
|
|
107
159
|
if (killTimer !== undefined)
|
|
108
160
|
clearTimeout(killTimer);
|
|
109
161
|
options.cancelSignal?.removeEventListener('abort', onAbort);
|
|
@@ -155,6 +207,21 @@ export async function execAgent(options) {
|
|
|
155
207
|
const terminate = () => {
|
|
156
208
|
if (settled || killTimer !== undefined)
|
|
157
209
|
return;
|
|
210
|
+
// Both clocks are disarmed here rather than in `finish`, because a
|
|
211
|
+
// termination takes up to KILL_GRACE_MS and neither clock may re-decide
|
|
212
|
+
// the reason during it. A ceiling that lapses while a stalled run is
|
|
213
|
+
// being killed would otherwise overwrite "no output for 15 minutes" with
|
|
214
|
+
// "timed out", sending whoever reads it to the wrong flag — and the
|
|
215
|
+
// child's dying output would likewise re-arm a watchdog for a run that
|
|
216
|
+
// is already on its way out.
|
|
217
|
+
if (timer !== undefined) {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
timer = undefined;
|
|
220
|
+
}
|
|
221
|
+
if (idleTimer !== undefined) {
|
|
222
|
+
clearTimeout(idleTimer);
|
|
223
|
+
idleTimer = undefined;
|
|
224
|
+
}
|
|
158
225
|
signalTree('SIGTERM');
|
|
159
226
|
killTimer = setTimeout(() => {
|
|
160
227
|
signalTree('SIGKILL');
|
|
@@ -167,11 +234,30 @@ export async function execAgent(options) {
|
|
|
167
234
|
stderr: clamp(stderr),
|
|
168
235
|
exitCode: null,
|
|
169
236
|
timedOut,
|
|
237
|
+
idleTimedOut,
|
|
170
238
|
cancelled,
|
|
171
239
|
error: stopReason(),
|
|
172
240
|
});
|
|
173
241
|
}, KILL_GRACE_MS);
|
|
174
242
|
};
|
|
243
|
+
/**
|
|
244
|
+
* (Re)start the silence window.
|
|
245
|
+
*
|
|
246
|
+
* Armed once the child exists and again on every chunk it writes, so the
|
|
247
|
+
* window measures time since the last sign of life rather than time since
|
|
248
|
+
* the run began. Deliberately a no-op once a kill is under way: the run's
|
|
249
|
+
* fate is already decided, and the flag that explains it is already set.
|
|
250
|
+
*/
|
|
251
|
+
const armIdleWatchdog = () => {
|
|
252
|
+
if (idleMs <= 0 || settled || killTimer !== undefined)
|
|
253
|
+
return;
|
|
254
|
+
if (idleTimer !== undefined)
|
|
255
|
+
clearTimeout(idleTimer);
|
|
256
|
+
idleTimer = setTimeout(() => {
|
|
257
|
+
idleTimedOut = true;
|
|
258
|
+
terminate();
|
|
259
|
+
}, idleMs);
|
|
260
|
+
};
|
|
175
261
|
function onAbort() {
|
|
176
262
|
cancelled = true;
|
|
177
263
|
terminate();
|
|
@@ -185,6 +271,7 @@ export async function execAgent(options) {
|
|
|
185
271
|
options.cancelSignal?.addEventListener('abort', onAbort, { once: true });
|
|
186
272
|
child.stdout?.on('data', (chunk) => {
|
|
187
273
|
const text = chunk.toString();
|
|
274
|
+
armIdleWatchdog();
|
|
188
275
|
// Capture is bounded; the callback is not. A parser that stops being fed
|
|
189
276
|
// at 512 KB would report stale progress and salvage the wrong text for
|
|
190
277
|
// the rest of a long run — and the callback keeps nothing per chunk.
|
|
@@ -198,6 +285,10 @@ export async function execAgent(options) {
|
|
|
198
285
|
}
|
|
199
286
|
});
|
|
200
287
|
child.stderr?.on('data', (chunk) => {
|
|
288
|
+
// stderr counts as life exactly as much as stdout does. An agent that
|
|
289
|
+
// logs its progress to stderr and answers on stdout — several do — is
|
|
290
|
+
// plainly working, and a watchdog watching one stream would kill it.
|
|
291
|
+
armIdleWatchdog();
|
|
201
292
|
if (stderr.length < MAX_OUTPUT_CHARS)
|
|
202
293
|
stderr += chunk.toString();
|
|
203
294
|
});
|
|
@@ -208,21 +299,27 @@ export async function execAgent(options) {
|
|
|
208
299
|
stderr: clamp(stderr),
|
|
209
300
|
exitCode: null,
|
|
210
301
|
timedOut,
|
|
302
|
+
idleTimedOut,
|
|
211
303
|
cancelled,
|
|
212
304
|
error: err.message,
|
|
213
305
|
});
|
|
214
306
|
});
|
|
215
307
|
child.on('close', (code) => {
|
|
216
308
|
finish({
|
|
217
|
-
ok: !timedOut && !cancelled && code === 0,
|
|
309
|
+
ok: !timedOut && !idleTimedOut && !cancelled && code === 0,
|
|
218
310
|
stdout: clamp(stdout),
|
|
219
311
|
stderr: clamp(stderr),
|
|
220
312
|
exitCode: code,
|
|
221
313
|
timedOut,
|
|
314
|
+
idleTimedOut,
|
|
222
315
|
cancelled,
|
|
223
316
|
error: stopReason(),
|
|
224
317
|
});
|
|
225
318
|
});
|
|
319
|
+
// Started here, not at the top of the function: the window measures the
|
|
320
|
+
// child's silence, and a child that has not been spawned yet — or that
|
|
321
|
+
// failed to spawn at all — has nothing to be silent about.
|
|
322
|
+
armIdleWatchdog();
|
|
226
323
|
// stdin may already be closed if the command failed to start.
|
|
227
324
|
try {
|
|
228
325
|
child.stdin?.on('error', () => {
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -50,6 +50,17 @@ export interface PromptContext {
|
|
|
50
50
|
* They cannot tell those apart from a sentence that says neither.
|
|
51
51
|
*/
|
|
52
52
|
timeoutMs?: number;
|
|
53
|
+
/**
|
|
54
|
+
* How long the run may produce nothing at all before it is killed, in
|
|
55
|
+
* milliseconds. ``0`` is `--idle-timeout 0`: no watchdog.
|
|
56
|
+
*
|
|
57
|
+
* Threaded in for the same reason as {@link timeoutMs}, and it now carries
|
|
58
|
+
* most of the weight: with no wall-clock ceiling by default, this is usually
|
|
59
|
+
* the only local limit there is. It is also a limit an agent can act on,
|
|
60
|
+
* which a duration is not — "do not go completely silent for a quarter of an
|
|
61
|
+
* hour" is advice about how to work, not a request to hurry.
|
|
62
|
+
*/
|
|
63
|
+
idleTimeoutMs?: number;
|
|
53
64
|
}
|
|
54
65
|
/** Build the stdin prompt for one run. */
|
|
55
66
|
export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
|
package/dist/agent/prompt.js
CHANGED
|
@@ -23,31 +23,68 @@ function renderBudget(timeoutMs) {
|
|
|
23
23
|
return `about ${Math.max(1, Math.round(timeoutMs / 1_000))} seconds`;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* The bullet that tells the agent
|
|
26
|
+
* The bullet that tells the agent what its run is actually bounded by.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* Three shapes, and each says something different about how to work:
|
|
29
|
+
*
|
|
30
|
+
* * **A wall-clock ceiling** (`--timeout <n>`) means budget the turn. Naming
|
|
31
|
+
* the number is what stops an agent from treating two hours like five
|
|
32
|
+
* minutes — the failure this replaced was a multi-minute coding task killed
|
|
33
|
+
* at 300s having been told only that "a limit" existed. A watchdog set
|
|
34
|
+
* *shorter* than the ceiling is named alongside it, because it is then the
|
|
35
|
+
* deadline that actually fires first: told only "you have two hours", an
|
|
36
|
+
* agent has no reason to break up the quiet forty-minute step that kills it
|
|
37
|
+
* at fifteen.
|
|
38
|
+
* * **The watchdog alone** — the default — means the turn is bounded by
|
|
39
|
+
* *silence*, not duration. That is a limit an agent can actually act on, and
|
|
40
|
+
* it must be stated as what it is, because an agent told it has "no limit"
|
|
41
|
+
* and then killed at fifteen minutes of quiet was told something false.
|
|
42
|
+
* * **Neither** means nothing local will stop the run, which still needs a word
|
|
43
|
+
* about pace: nothing is posted until the turn ends, so a run that explores
|
|
44
|
+
* forever reads, from the thread, exactly like one that crashed.
|
|
34
45
|
*/
|
|
35
|
-
function timeBudgetLines(timeoutMs) {
|
|
36
|
-
|
|
46
|
+
function timeBudgetLines(timeoutMs, idleTimeoutMs) {
|
|
47
|
+
// Said nothing about by the caller: say nothing back, rather than describe a
|
|
48
|
+
// regime this prompt has not been told is in force.
|
|
49
|
+
if (timeoutMs === undefined && idleTimeoutMs === undefined)
|
|
37
50
|
return [];
|
|
38
|
-
|
|
51
|
+
const wallMs = timeoutMs ?? 0;
|
|
52
|
+
const idleMs = idleTimeoutMs ?? 0;
|
|
53
|
+
if (wallMs > 0) {
|
|
54
|
+
return [
|
|
55
|
+
`- You have ${renderBudget(wallMs)} for this whole turn. Past that you are killed`,
|
|
56
|
+
' mid-thought and the person waiting gets nothing, so spend the budget on the',
|
|
57
|
+
' work and leave yourself room to write the answer.',
|
|
58
|
+
// Only when it can actually fire first. A watchdog window at or above the
|
|
59
|
+
// ceiling is unreachable — a run silent that long has already overrun —
|
|
60
|
+
// and naming an unreachable limit is noise the agent has to reason past.
|
|
61
|
+
...(idleMs > 0 && idleMs < wallMs
|
|
62
|
+
? [
|
|
63
|
+
'- Going quiet ends the turn early, well before that: produce no output at',
|
|
64
|
+
` all for ${renderBudget(idleMs)} and the run is killed and reported as`,
|
|
65
|
+
' stalled. Ordinary work clears that on its own; a single step that will',
|
|
66
|
+
' be silent for longer is the one thing to break up.',
|
|
67
|
+
]
|
|
68
|
+
: []),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
if (idleMs > 0) {
|
|
39
72
|
return [
|
|
40
|
-
'- There is no fixed time limit on this turn, so work that genuinely takes',
|
|
41
|
-
'
|
|
42
|
-
|
|
43
|
-
'
|
|
44
|
-
'
|
|
73
|
+
'- There is no fixed time limit on this turn, so work that genuinely takes a',
|
|
74
|
+
' while is fine — finish it rather than cutting it short. What is limited is',
|
|
75
|
+
` silence: produce no output at all for ${renderBudget(idleMs)} and the run is`,
|
|
76
|
+
' killed and reported as stalled. Ordinary work clears that on its own; a',
|
|
77
|
+
' single step that will be quiet for longer than that is the one thing to',
|
|
78
|
+
' break up. Nothing you write is posted until the turn ends, so keep moving',
|
|
79
|
+
' toward an answer rather than exploring indefinitely.',
|
|
45
80
|
];
|
|
46
81
|
}
|
|
47
82
|
return [
|
|
48
|
-
|
|
49
|
-
'
|
|
50
|
-
'
|
|
83
|
+
'- There is no time limit on this turn and nothing local will stop it, so work',
|
|
84
|
+
' that genuinely takes a while is fine — finish it rather than cutting it',
|
|
85
|
+
' short. But nothing you write is posted until the turn ends, so a run that',
|
|
86
|
+
' explores indefinitely is indistinguishable from one that crashed. Keep',
|
|
87
|
+
' moving toward an answer.',
|
|
51
88
|
];
|
|
52
89
|
}
|
|
53
90
|
/**
|
|
@@ -187,7 +224,7 @@ export function buildPrompt(run, context) {
|
|
|
187
224
|
// not buried under the tool advice: how long the agent has changes how it
|
|
188
225
|
// should approach the work, so it has to be read before any of the "go and
|
|
189
226
|
// look" bullets below.
|
|
190
|
-
...timeBudgetLines(context.timeoutMs),
|
|
227
|
+
...timeBudgetLines(context.timeoutMs, context.idleTimeoutMs),
|
|
191
228
|
// Claude Code lazy-loads part of its catalog — WebSearch/WebFetch among
|
|
192
229
|
// them — so a run that inspects its loaded tools concludes it cannot
|
|
193
230
|
// browse and says so to a customer, when one ToolSearch call away it
|
package/dist/agent/run.d.ts
CHANGED
|
@@ -26,18 +26,36 @@ import type { spawn } from 'node:child_process';
|
|
|
26
26
|
import WebSocket from 'ws';
|
|
27
27
|
import type { AgentCredentials } from './credentials.js';
|
|
28
28
|
/**
|
|
29
|
-
* Default ceiling on one agent invocation.
|
|
29
|
+
* Default ceiling on one agent invocation: none.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* and
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
31
|
+
* The wall clock kept being raised — five minutes, then two hours — because it
|
|
32
|
+
* was answering the wrong question. "How long has this run been going?" does
|
|
33
|
+
* not distinguish a wedged child from a productive one; it only distinguishes
|
|
34
|
+
* long work from short, and killing long work is precisely the failure the two
|
|
35
|
+
* previous defaults each produced in production. What the harness can actually
|
|
36
|
+
* observe is whether the child is *doing* anything, which is what
|
|
37
|
+
* {@link DEFAULT_IDLE_TIMEOUT_MS} watches.
|
|
38
|
+
*
|
|
39
|
+
* So a run is now bounded by three things, none of them a fixed duration:
|
|
40
|
+
* the inactivity watchdog (default 15 minutes of total silence), the server's
|
|
41
|
+
* lease on the run (heartbeats stop, the row is reaped), and the Stop button in
|
|
42
|
+
* ProhostAI (which reaches this machine on the next heartbeat). `--timeout`
|
|
43
|
+
* remains fully supported for anyone who wants a hard ceiling as well.
|
|
44
|
+
*/
|
|
45
|
+
export declare const DEFAULT_EXEC_TIMEOUT_MS = 0;
|
|
46
|
+
/**
|
|
47
|
+
* Default silence window before a run is treated as stalled.
|
|
48
|
+
*
|
|
49
|
+
* Fifteen minutes is chosen against what real work looks like, not against
|
|
50
|
+
* what feels responsive: an agent mid-task emits stream events, tool calls and
|
|
51
|
+
* log lines continuously, so the gaps in a healthy run are seconds. The long
|
|
52
|
+
* tail that legitimately goes quiet is a single slow tool call — a test suite,
|
|
53
|
+
* a build, a big fetch — and fifteen minutes clears those comfortably while
|
|
54
|
+
* still catching the failure this exists for, a child wedged on a stdin prompt
|
|
55
|
+
* that heartbeats look perfectly healthy for and that burns the operator's
|
|
56
|
+
* tokens and their agent's availability indefinitely.
|
|
39
57
|
*/
|
|
40
|
-
export declare const
|
|
58
|
+
export declare const DEFAULT_IDLE_TIMEOUT_MS = 900000;
|
|
41
59
|
export interface AgentRunOptions {
|
|
42
60
|
/** Shell command that runs the agent, e.g. ``claude -p``. */
|
|
43
61
|
exec: string;
|
|
@@ -46,8 +64,16 @@ export interface AgentRunOptions {
|
|
|
46
64
|
url?: string;
|
|
47
65
|
/** Machine name shown in the ProhostAI UI. Defaults to the OS hostname. */
|
|
48
66
|
machine?: string;
|
|
49
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Per-run wall-clock ceiling. ``0`` disables it, and that is now the default
|
|
69
|
+
* — see {@link DEFAULT_EXEC_TIMEOUT_MS}.
|
|
70
|
+
*/
|
|
50
71
|
timeoutMs?: number;
|
|
72
|
+
/**
|
|
73
|
+
* How long a run may produce no output at all before it is killed. ``0``
|
|
74
|
+
* disables the watchdog — see {@link DEFAULT_IDLE_TIMEOUT_MS}.
|
|
75
|
+
*/
|
|
76
|
+
idleTimeoutMs?: number;
|
|
51
77
|
/** Print the reply that would be posted and make no writes. */
|
|
52
78
|
dryRun?: boolean;
|
|
53
79
|
/** Execute runs whose signature could not be verified. Off by default. */
|
|
@@ -120,6 +146,8 @@ interface PreparedMcp {
|
|
|
120
146
|
mcpConfigPath?: string;
|
|
121
147
|
/** Codex: entries in the single verified `mcp_servers` override. */
|
|
122
148
|
codexMcpEntries?: string[];
|
|
149
|
+
/** Codex: verified non-`mcp_servers` overrides, e.g. plugins off. */
|
|
150
|
+
codexConfigOverrides?: string[];
|
|
123
151
|
/** Credentials moved out of Codex's process argv. */
|
|
124
152
|
execEnv?: NodeJS.ProcessEnv;
|
|
125
153
|
/** ProhostAI tools are actually reachable from this invocation. */
|