@myvillage/cli 2.13.0 → 2.13.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/package.json +1 -1
- package/src/agent-runtime/loop.js +282 -135
- package/src/commands/agent-mcp.js +2 -2
- package/src/utils/agent-scaffolder.js +1 -1
package/package.json
CHANGED
|
@@ -112,15 +112,24 @@ export async function agentLoop(agentName, { signal }) {
|
|
|
112
112
|
const anthropic = createAnthropic({ apiKey });
|
|
113
113
|
model = anthropic(modelId);
|
|
114
114
|
}
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
115
|
+
// Output cap for each model reply (each step), not for the whole run — the AI
|
|
116
|
+
// SDK applies maxOutputTokens per generation call. Despite the config key's
|
|
117
|
+
// name it never bounded a run's total spend; the MVT balance does that.
|
|
118
|
+
const maxTokens = config.brain?.max_tokens_per_loop || 16000;
|
|
119
|
+
// Max agentic steps (one model turn + its tool executions) per run. Long,
|
|
120
|
+
// many-step tasks are the point of these agents, so by default there is NO
|
|
121
|
+
// cap: the run ends when the model finishes, not when a counter runs out. A
|
|
122
|
+
// villager can still set brain.max_steps_per_loop to bound an agent. What does
|
|
123
|
+
// end a run early is getting stuck — see stalledOnToolErrors below, which
|
|
124
|
+
// replaces the step cap as the guard against the "thrash on a broken tool"
|
|
125
|
+
// failure the old 25-step cap used to catch (slowly).
|
|
126
|
+
const maxSteps = Number(config.brain?.max_steps_per_loop) || 0;
|
|
127
|
+
// A step is one model reply plus the tools it calls. A single step that runs
|
|
128
|
+
// this long is hung (a stalled provider stream, a tool that never answers), so
|
|
129
|
+
// it's aborted and the task resumes from its last finished step.
|
|
130
|
+
const stepTimeoutMs = (Number(config.brain?.step_timeout_minutes) || 30) * 60_000;
|
|
131
|
+
const stopConditions = [stalledOnToolErrors(STALL_STEPS)];
|
|
132
|
+
if (maxSteps > 0) stopConditions.push(stepCountIs(maxSteps));
|
|
124
133
|
|
|
125
134
|
// Refresh BEFORE the first MCP connect. Whoever spawned us put a snapshot of
|
|
126
135
|
// MYVILLAGE_ACCESS_TOKEN in our env, and the MCP client bakes it into its
|
|
@@ -173,6 +182,11 @@ export async function agentLoop(agentName, { signal }) {
|
|
|
173
182
|
let lastCheckIn = null;
|
|
174
183
|
let iteration = 0;
|
|
175
184
|
const recentActions = []; // Track actions across iterations to avoid duplicates
|
|
185
|
+
// A task whose run was cut short (rate limit after retries, provider outage,
|
|
186
|
+
// hung step, out of MVTs) is carried here and picked up again on the next
|
|
187
|
+
// iteration with its conversation so far, instead of being marked FAILED and
|
|
188
|
+
// losing the work. { task, messages, attempts, audit, toolCalls, tokens, startedAt, lastError }
|
|
189
|
+
let resume = null;
|
|
176
190
|
|
|
177
191
|
while (!signal.aborted) {
|
|
178
192
|
// Check active hours
|
|
@@ -181,9 +195,10 @@ export async function agentLoop(agentName, { signal }) {
|
|
|
181
195
|
continue;
|
|
182
196
|
}
|
|
183
197
|
|
|
184
|
-
// For manual-only agents (interval=0), only run once then exit
|
|
198
|
+
// For manual-only agents (interval=0), only run once then exit — unless a
|
|
199
|
+
// task is waiting to be resumed.
|
|
185
200
|
const intervalMs = getNextCheckInMs(config.schedule);
|
|
186
|
-
if (intervalMs === 0 && iteration > 0) {
|
|
201
|
+
if (intervalMs === 0 && iteration > 0 && !resume) {
|
|
187
202
|
break;
|
|
188
203
|
}
|
|
189
204
|
|
|
@@ -243,11 +258,23 @@ export async function agentLoop(agentName, { signal }) {
|
|
|
243
258
|
// Tracks whether the task's tool calls actually succeeded. The LLM
|
|
244
259
|
// sometimes "summarizes" a tool error into a falsely-confident final
|
|
245
260
|
// response — we don't want to trust the model's word about success.
|
|
246
|
-
|
|
261
|
+
// A resumed task carries its audit forward so the verdict covers the
|
|
262
|
+
// whole task, not just the last stretch.
|
|
263
|
+
const taskActionAudit = resume?.audit || {
|
|
247
264
|
actionToolsCalled: 0,
|
|
248
265
|
actionToolsSucceeded: 0,
|
|
249
266
|
toolErrors: [], // { tool, message }
|
|
250
267
|
};
|
|
268
|
+
// The conversation so far (everything after the system prompt), kept
|
|
269
|
+
// current by onStepFinish so a failure mid-run can resume from here.
|
|
270
|
+
let runMessages = null;
|
|
271
|
+
let modelFinished = false;
|
|
272
|
+
let stepsThisRun = 0;
|
|
273
|
+
let tokensThisRun = 0;
|
|
274
|
+
const resuming = resume;
|
|
275
|
+
resume = null;
|
|
276
|
+
// Per-call record of what the agent did, attached to the task output.
|
|
277
|
+
const toolCallSummary = resuming?.toolCalls || [];
|
|
251
278
|
|
|
252
279
|
try {
|
|
253
280
|
// Read prompt.md fresh each iteration (villager may have edited it).
|
|
@@ -283,7 +310,9 @@ export async function agentLoop(agentName, { signal }) {
|
|
|
283
310
|
|
|
284
311
|
// Try to pull an assigned task first. Tasks take priority over ambient
|
|
285
312
|
// feed-monitoring. If nothing is queued, fall through to the default.
|
|
286
|
-
if (
|
|
313
|
+
if (resuming) {
|
|
314
|
+
activeTask = resuming.task;
|
|
315
|
+
} else if (config.man?.village_agent_id) {
|
|
287
316
|
activeTask = await pollAndClaim(config.man.village_agent_id, agentDir);
|
|
288
317
|
}
|
|
289
318
|
|
|
@@ -316,7 +345,11 @@ Guidelines:
|
|
|
316
345
|
|
|
317
346
|
context = `## TASK (id=${activeTask.id}, type=${activeTask.taskType})\n${instructionText}\n\n---\n\n## AMBIENT CONTEXT (for awareness only)\n${context}`;
|
|
318
347
|
|
|
319
|
-
|
|
348
|
+
if (resuming) {
|
|
349
|
+
logActivity(agentDir, { type: 'task_resumed', taskId: activeTask.id, attempt: resuming.attempts, after: resuming.lastError });
|
|
350
|
+
} else {
|
|
351
|
+
logActivity(agentDir, { type: 'task_claimed', taskId: activeTask.id, taskType: activeTask.taskType });
|
|
352
|
+
}
|
|
320
353
|
}
|
|
321
354
|
|
|
322
355
|
// Feed is no longer pushed into context; nothing to count here.
|
|
@@ -329,14 +362,69 @@ Guidelines:
|
|
|
329
362
|
events: eventsFound,
|
|
330
363
|
});
|
|
331
364
|
|
|
365
|
+
const conversation = resuming
|
|
366
|
+
? [
|
|
367
|
+
...resuming.messages,
|
|
368
|
+
{
|
|
369
|
+
role: 'user',
|
|
370
|
+
content:
|
|
371
|
+
`(Your work on this task was interrupted: ${resuming.lastError}. Pick up where you left off. ` +
|
|
372
|
+
'Everything you did above already happened, so do not repeat actions you have already taken.)',
|
|
373
|
+
},
|
|
374
|
+
]
|
|
375
|
+
: [{ role: 'user', content: context }];
|
|
376
|
+
// Log tool calls and count activity as each step finishes, not after the
|
|
377
|
+
// whole run: a long run shows progress in the Logs panel as it goes, and a
|
|
378
|
+
// run that fails mid-way keeps the record of what it already did. Also
|
|
379
|
+
// audit action-tool success so we don't trust the model's final text about
|
|
380
|
+
// whether a task worked. `toolCallSummary` accumulates a structured
|
|
381
|
+
// per-call record that we attach to the task output so the Activity panel
|
|
382
|
+
// can show what the agent actually did — not just a tool-call count.
|
|
383
|
+
const recordStep = (step) => {
|
|
384
|
+
for (const tc of step.toolCalls || []) {
|
|
385
|
+
activity.toolCalls++;
|
|
386
|
+
if (tc.toolName === 'post_create') activity.postsCreated++;
|
|
387
|
+
if (tc.toolName === 'comment_create') activity.commentsCreated++;
|
|
388
|
+
if (tc.toolName === 'vote_cast') activity.votesGiven++;
|
|
389
|
+
collectActions(tc.toolName, tc.input ?? tc.args);
|
|
390
|
+
}
|
|
391
|
+
for (const tr of stepToolOutcomes(step)) {
|
|
392
|
+
const errored = isToolResultError(tr);
|
|
393
|
+
const resultSummary = summarizeToolResult(tr);
|
|
394
|
+
auditToolCall(taskActionAudit, tr.toolName, errored, tr);
|
|
395
|
+
toolCallSummary.push({ tool: tr.toolName, args: tr.input, result: resultSummary, ok: !errored });
|
|
396
|
+
logActivity(agentDir, { type: 'tool_call', tool: tr.toolName, args: tr.input, result: resultSummary, ok: !errored });
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// Record actions for dedup in future iterations
|
|
401
|
+
const collectActions = (toolName, args) => {
|
|
402
|
+
if (toolName === 'comment_create' && args?.postId) {
|
|
403
|
+
recentActions.push({ type: 'comment', postId: args.postId, ts: new Date().toISOString() });
|
|
404
|
+
} else if (toolName === 'post_create' && args?.communitySlug) {
|
|
405
|
+
recentActions.push({ type: 'post', community: args.communitySlug, ts: new Date().toISOString() });
|
|
406
|
+
} else if (toolName === 'vote_cast' && args?.targetId) {
|
|
407
|
+
recentActions.push({ type: 'vote', targetId: args.targetId, targetType: args.targetType, ts: new Date().toISOString() });
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
332
411
|
// Call LLM with tools
|
|
333
412
|
// AI SDK v6 removed `maxSteps` — the loop now uses `stopWhen` with
|
|
334
413
|
// a StopCondition predicate. The default is `stepCountIs(1)`, so
|
|
335
414
|
// without this the model gets exactly ONE round of tool calls and
|
|
336
|
-
// returns immediately. We
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
415
|
+
// returns immediately. We let it run until it finishes (or stalls, or
|
|
416
|
+
// hits a villager-set step cap — see stopConditions above) so a long
|
|
417
|
+
// task can do discover → reason → act for as many turns as it needs.
|
|
418
|
+
//
|
|
419
|
+
// Reliability for long runs: each model call retries rate limits and
|
|
420
|
+
// provider hiccups with backoff (maxRetries; 2s doubling, honoring
|
|
421
|
+
// Retry-After), a hung step is aborted after stepTimeoutMs, and Stop
|
|
422
|
+
// interrupts mid-run via abortSignal. If the run still fails, the catch
|
|
423
|
+
// below carries the task into the next iteration (see `resume`).
|
|
424
|
+
//
|
|
425
|
+
// Context: every tool result stays in the conversation, so a long run
|
|
426
|
+
// would eventually overflow the model's window. trimOldToolResults
|
|
427
|
+
// shortens older tool outputs once the conversation gets large.
|
|
340
428
|
//
|
|
341
429
|
// Anthropic prompt caching: we mark the system block as ephemeral so
|
|
342
430
|
// everything before it (tool schemas + system prompt) is cached.
|
|
@@ -346,6 +434,7 @@ Guidelines:
|
|
|
346
434
|
// The breakpoint is namespaced under `anthropic`, so OpenAI calls
|
|
347
435
|
// silently ignore it. Cache TTL is ~5 min; agents that loop more
|
|
348
436
|
// often than that stay warm and pay full price only on the first hit.
|
|
437
|
+
runMessages = conversation;
|
|
349
438
|
const result = await generateText({
|
|
350
439
|
model,
|
|
351
440
|
messages: [
|
|
@@ -356,12 +445,30 @@ Guidelines:
|
|
|
356
445
|
anthropic: { cacheControl: { type: 'ephemeral' } },
|
|
357
446
|
},
|
|
358
447
|
},
|
|
359
|
-
|
|
448
|
+
...conversation,
|
|
360
449
|
],
|
|
361
450
|
tools,
|
|
362
|
-
stopWhen:
|
|
451
|
+
stopWhen: stopConditions,
|
|
363
452
|
maxOutputTokens: maxTokens,
|
|
453
|
+
maxRetries: MODEL_MAX_RETRIES,
|
|
454
|
+
timeout: { stepMs: stepTimeoutMs },
|
|
455
|
+
abortSignal: signal,
|
|
456
|
+
prepareStep: ({ messages }) => trimOldToolResults(messages),
|
|
457
|
+
onStepFinish: (step) => {
|
|
458
|
+
stepsThisRun++;
|
|
459
|
+
tokensThisRun += step.usage?.totalTokens || 0;
|
|
460
|
+
runMessages = [...conversation, ...(step.response?.messages || [])];
|
|
461
|
+
recordStep(step);
|
|
462
|
+
},
|
|
364
463
|
});
|
|
464
|
+
modelFinished = true;
|
|
465
|
+
|
|
466
|
+
if (result.steps?.length && stalledOnToolErrors(STALL_STEPS)({ steps: result.steps })) {
|
|
467
|
+
logActivity(agentDir, {
|
|
468
|
+
type: 'error',
|
|
469
|
+
error: `Stopped after ${STALL_STEPS} steps in a row where every tool call failed. Check the tool errors above.`,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
365
472
|
|
|
366
473
|
// Log LLM response. Cache metrics come from the Anthropic provider
|
|
367
474
|
// metadata — surfacing them lets `agent logs` show whether caching is
|
|
@@ -371,9 +478,9 @@ Guidelines:
|
|
|
371
478
|
const anthropicMeta = result.providerMetadata?.anthropic || {};
|
|
372
479
|
logActivity(agentDir, {
|
|
373
480
|
type: 'llm_response',
|
|
374
|
-
// Keep the full response: at ~4 chars/token,
|
|
375
|
-
//
|
|
376
|
-
text: (result.text || '').slice(0,
|
|
481
|
+
// Keep the full response: at ~4 chars/token, 64k chars holds a maxed-out
|
|
482
|
+
// 16k-token reply, so the Logs panel no longer clips mid-sentence.
|
|
483
|
+
text: (result.text || '').slice(0, 64000),
|
|
377
484
|
tokensUsed: {
|
|
378
485
|
prompt: result.usage?.inputTokens || 0,
|
|
379
486
|
completion: result.usage?.outputTokens || 0,
|
|
@@ -383,106 +490,6 @@ Guidelines:
|
|
|
383
490
|
},
|
|
384
491
|
});
|
|
385
492
|
|
|
386
|
-
// Log tool calls and count activity. Also audit action-tool success
|
|
387
|
-
// so we don't trust the model's final text about whether a task worked.
|
|
388
|
-
// `toolCallSummary` accumulates a structured per-call record that we
|
|
389
|
-
// attach to the task output so the Activity panel can show what the
|
|
390
|
-
// agent actually did — not just a tool-call count.
|
|
391
|
-
const toolCallSummary = [];
|
|
392
|
-
if (result.steps?.length) {
|
|
393
|
-
for (const step of result.steps) {
|
|
394
|
-
if (step.toolCalls?.length) {
|
|
395
|
-
for (const tc of step.toolCalls) {
|
|
396
|
-
activity.toolCalls++;
|
|
397
|
-
if (tc.toolName === 'post_create') activity.postsCreated++;
|
|
398
|
-
if (tc.toolName === 'comment_create') activity.commentsCreated++;
|
|
399
|
-
if (tc.toolName === 'vote_cast') activity.votesGiven++;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
if (step.toolCalls?.length && step.toolResults?.length) {
|
|
403
|
-
for (let i = 0; i < step.toolResults.length; i++) {
|
|
404
|
-
const tr = step.toolResults[i];
|
|
405
|
-
// AI SDK v6 renamed `args` → `input`. Try v6 first.
|
|
406
|
-
const args = step.toolCalls[i]?.input ?? step.toolCalls[i]?.args;
|
|
407
|
-
const errored = isToolResultError(tr);
|
|
408
|
-
const resultSummary = summarizeToolResult(tr);
|
|
409
|
-
auditToolCall(taskActionAudit, tr.toolName, errored, tr);
|
|
410
|
-
toolCallSummary.push({
|
|
411
|
-
tool: tr.toolName,
|
|
412
|
-
args,
|
|
413
|
-
result: resultSummary,
|
|
414
|
-
ok: !errored,
|
|
415
|
-
});
|
|
416
|
-
logActivity(agentDir, {
|
|
417
|
-
type: 'tool_call',
|
|
418
|
-
tool: tr.toolName,
|
|
419
|
-
args,
|
|
420
|
-
result: resultSummary,
|
|
421
|
-
ok: !errored,
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
} else if (step.toolResults?.length) {
|
|
425
|
-
for (const tr of step.toolResults) {
|
|
426
|
-
const errored = isToolResultError(tr);
|
|
427
|
-
const resultSummary = summarizeToolResult(tr);
|
|
428
|
-
auditToolCall(taskActionAudit, tr.toolName, errored, tr);
|
|
429
|
-
toolCallSummary.push({
|
|
430
|
-
tool: tr.toolName,
|
|
431
|
-
result: resultSummary,
|
|
432
|
-
ok: !errored,
|
|
433
|
-
});
|
|
434
|
-
logActivity(agentDir, {
|
|
435
|
-
type: 'tool_call',
|
|
436
|
-
tool: tr.toolName,
|
|
437
|
-
result: resultSummary,
|
|
438
|
-
ok: !errored,
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
} else if (result.toolCalls?.length) {
|
|
444
|
-
for (const tc of result.toolCalls) {
|
|
445
|
-
activity.toolCalls++;
|
|
446
|
-
if (tc.toolName === 'post_create') activity.postsCreated++;
|
|
447
|
-
if (tc.toolName === 'comment_create') activity.commentsCreated++;
|
|
448
|
-
if (tc.toolName === 'vote_cast') activity.votesGiven++;
|
|
449
|
-
// No paired result here — assume executed, can't audit.
|
|
450
|
-
const args = tc.input ?? tc.args;
|
|
451
|
-
toolCallSummary.push({
|
|
452
|
-
tool: tc.toolName,
|
|
453
|
-
args,
|
|
454
|
-
result: 'executed',
|
|
455
|
-
});
|
|
456
|
-
logActivity(agentDir, {
|
|
457
|
-
type: 'tool_call',
|
|
458
|
-
tool: tc.toolName,
|
|
459
|
-
args,
|
|
460
|
-
result: 'executed',
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// Record actions for dedup in future iterations
|
|
466
|
-
const collectActions = (toolName, args) => {
|
|
467
|
-
if (toolName === 'comment_create' && args?.postId) {
|
|
468
|
-
recentActions.push({ type: 'comment', postId: args.postId, ts: new Date().toISOString() });
|
|
469
|
-
} else if (toolName === 'post_create' && args?.communitySlug) {
|
|
470
|
-
recentActions.push({ type: 'post', community: args.communitySlug, ts: new Date().toISOString() });
|
|
471
|
-
} else if (toolName === 'vote_cast' && args?.targetId) {
|
|
472
|
-
recentActions.push({ type: 'vote', targetId: args.targetId, targetType: args.targetType, ts: new Date().toISOString() });
|
|
473
|
-
}
|
|
474
|
-
};
|
|
475
|
-
if (result.steps?.length) {
|
|
476
|
-
for (const step of result.steps) {
|
|
477
|
-
for (const tc of step.toolCalls || []) {
|
|
478
|
-
collectActions(tc.toolName, tc.input ?? tc.args);
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
} else if (result.toolCalls?.length) {
|
|
482
|
-
for (const tc of result.toolCalls) {
|
|
483
|
-
collectActions(tc.toolName, tc.input ?? tc.args);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
493
|
// Keep only last 50 actions to bound memory
|
|
487
494
|
if (recentActions.length > 50) recentActions.splice(0, recentActions.length - 50);
|
|
488
495
|
|
|
@@ -523,8 +530,8 @@ Guidelines:
|
|
|
523
530
|
toolErrors: taskActionAudit.toolErrors,
|
|
524
531
|
note: 'Marked FAILED because the action tools did not succeed. The model\'s text may claim success but the underlying tool calls errored.',
|
|
525
532
|
},
|
|
526
|
-
tokensUsed: result.usage?.totalTokens || 0,
|
|
527
|
-
durationMs: Date.now() - loopStart,
|
|
533
|
+
tokensUsed: (resuming?.tokens || 0) + (result.usage?.totalTokens || 0),
|
|
534
|
+
durationMs: Date.now() - (resuming?.startedAt || loopStart),
|
|
528
535
|
});
|
|
529
536
|
logActivity(agentDir, {
|
|
530
537
|
type: 'task_failed',
|
|
@@ -539,8 +546,8 @@ Guidelines:
|
|
|
539
546
|
toolCalls: toolCallSummary,
|
|
540
547
|
toolErrors: taskActionAudit.toolErrors.length > 0 ? taskActionAudit.toolErrors : undefined,
|
|
541
548
|
},
|
|
542
|
-
tokensUsed: result.usage?.totalTokens || 0,
|
|
543
|
-
durationMs: Date.now() - loopStart,
|
|
549
|
+
tokensUsed: (resuming?.tokens || 0) + (result.usage?.totalTokens || 0),
|
|
550
|
+
durationMs: Date.now() - (resuming?.startedAt || loopStart),
|
|
544
551
|
});
|
|
545
552
|
logActivity(agentDir, { type: 'task_completed', taskId: activeTask.id });
|
|
546
553
|
}
|
|
@@ -578,7 +585,7 @@ Guidelines:
|
|
|
578
585
|
} catch (err) {
|
|
579
586
|
if (err instanceof LeaseLostError) {
|
|
580
587
|
logActivity(agentDir, { type: 'lease_lost', message: err.message });
|
|
581
|
-
console.log(
|
|
588
|
+
console.log(`\n[MyVillage] ${err.message}.\n`);
|
|
582
589
|
break;
|
|
583
590
|
}
|
|
584
591
|
if (err instanceof AgentPausedError) {
|
|
@@ -590,12 +597,41 @@ Guidelines:
|
|
|
590
597
|
type: 'error',
|
|
591
598
|
error: err.message,
|
|
592
599
|
});
|
|
593
|
-
//
|
|
594
|
-
|
|
600
|
+
// A task cut short mid-run (rate limit that outlasted the retries, provider
|
|
601
|
+
// outage, hung step, out of MVTs) resumes on the next iteration with its
|
|
602
|
+
// conversation so far. Only after RESUME_MAX_ATTEMPTS tries in a row with
|
|
603
|
+
// no new progress is it marked FAILED. A Stop from the villager is the
|
|
604
|
+
// exception: the process is exiting, so there is nothing to resume into.
|
|
605
|
+
if (activeTask && config.man?.village_agent_id && !signal.aborted && runMessages && !modelFinished) {
|
|
606
|
+
const madeProgress = stepsThisRun > 0;
|
|
607
|
+
const attempts = madeProgress ? 1 : (resuming?.attempts || 0) + 1;
|
|
608
|
+
if (attempts <= RESUME_MAX_ATTEMPTS) {
|
|
609
|
+
resume = {
|
|
610
|
+
task: activeTask,
|
|
611
|
+
messages: runMessages,
|
|
612
|
+
attempts,
|
|
613
|
+
audit: taskActionAudit,
|
|
614
|
+
toolCalls: toolCallSummary,
|
|
615
|
+
tokens: (resuming?.tokens || 0) + tokensThisRun,
|
|
616
|
+
startedAt: resuming?.startedAt || loopStart,
|
|
617
|
+
lastError: err.message.slice(0, 300),
|
|
618
|
+
};
|
|
619
|
+
logActivity(agentDir, {
|
|
620
|
+
type: 'task_interrupted',
|
|
621
|
+
taskId: activeTask.id,
|
|
622
|
+
attempt: attempts,
|
|
623
|
+
retryInMs: resumeDelayMs(attempts),
|
|
624
|
+
error: err.message,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// If a task was in flight when we crashed and won't be resumed, mark it
|
|
629
|
+
// FAILED so it isn't left IN_PROGRESS forever.
|
|
630
|
+
if (activeTask && config.man?.village_agent_id && !resume) {
|
|
595
631
|
try {
|
|
596
632
|
await completeAgentTask(config.man.village_agent_id, activeTask.id, {
|
|
597
|
-
errorMessage: err.message,
|
|
598
|
-
durationMs: Date.now() - loopStart,
|
|
633
|
+
errorMessage: signal.aborted ? 'Stopped before the task finished' : err.message,
|
|
634
|
+
durationMs: Date.now() - (resuming?.startedAt || loopStart),
|
|
599
635
|
});
|
|
600
636
|
logActivity(agentDir, { type: 'task_failed', taskId: activeTask.id });
|
|
601
637
|
} catch {
|
|
@@ -610,8 +646,11 @@ Guidelines:
|
|
|
610
646
|
logActivity(agentDir, { type: 'loop_end', iteration, duration_ms: duration });
|
|
611
647
|
updateHeartbeat(agentDir);
|
|
612
648
|
|
|
613
|
-
// Sleep until next interval
|
|
614
|
-
|
|
649
|
+
// Sleep until next interval — or, when a task is waiting to resume, only
|
|
650
|
+
// as long as its backoff (rate-limit buckets refill within a minute or two).
|
|
651
|
+
if (resume && !signal.aborted) {
|
|
652
|
+
await sleep(resumeDelayMs(resume.attempts), signal);
|
|
653
|
+
} else if (intervalMs > 0 && !signal.aborted) {
|
|
615
654
|
await sleep(intervalMs, signal);
|
|
616
655
|
}
|
|
617
656
|
}
|
|
@@ -620,6 +659,114 @@ Guidelines:
|
|
|
620
659
|
await cleanupMCPClients();
|
|
621
660
|
}
|
|
622
661
|
|
|
662
|
+
// ── Long-run settings ───────────────────────────────────
|
|
663
|
+
|
|
664
|
+
// Retries per model call for retryable errors (429 rate limits, 5xx, network).
|
|
665
|
+
// The AI SDK backs off 2s, 4s, 8s… and honors Retry-After, so 6 retries rides
|
|
666
|
+
// out about two minutes — enough for a shared-tier per-minute bucket to refill.
|
|
667
|
+
const MODEL_MAX_RETRIES = 6;
|
|
668
|
+
|
|
669
|
+
// A run ends early when this many steps in a row called tools and every one of
|
|
670
|
+
// them failed. That's an agent stuck on a broken tool (bad allowlist, expired
|
|
671
|
+
// auth, wrong arguments), not a long task — continuing only burns MVTs.
|
|
672
|
+
const STALL_STEPS = 8;
|
|
673
|
+
|
|
674
|
+
// How many times an interrupted task is resumed in a row WITHOUT making
|
|
675
|
+
// progress before it's marked FAILED. Any resumed run that completes a step
|
|
676
|
+
// resets the count, so a long task can be interrupted any number of times.
|
|
677
|
+
const RESUME_MAX_ATTEMPTS = 6;
|
|
678
|
+
|
|
679
|
+
// 1, 2, 4, 8, 15, 15 min between resume attempts: about 45 min in all, long
|
|
680
|
+
// enough to outlast a provider outage or for a villager to top up MVTs.
|
|
681
|
+
function resumeDelayMs(attempts) {
|
|
682
|
+
return Math.min(60_000 * 2 ** Math.max(0, attempts - 1), 15 * 60_000);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Once the conversation passes this size (~50k tokens at ~4 chars/token), older
|
|
686
|
+
// tool results are shortened so a long run never overflows the model's context
|
|
687
|
+
// window. Tool schemas and the system prompt sit on top of this, so it leaves
|
|
688
|
+
// room on a 128k-token model. The newest results are always kept whole.
|
|
689
|
+
const CONTEXT_TRIM_CHARS = 200_000;
|
|
690
|
+
const KEEP_RECENT_TOOL_MESSAGES = 6;
|
|
691
|
+
const TRIMMED_RESULT_CHARS = 400;
|
|
692
|
+
|
|
693
|
+
function stalledOnToolErrors(n) {
|
|
694
|
+
return ({ steps }) => {
|
|
695
|
+
if (steps.length < n) return false;
|
|
696
|
+
return steps.slice(-n).every(step => {
|
|
697
|
+
const outcomes = stepToolOutcomes(step);
|
|
698
|
+
return outcomes.length > 0 && outcomes.every(isToolResultError);
|
|
699
|
+
});
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Every tool outcome in a step, successes and failures alike, paired with the
|
|
704
|
+
// arguments it was called with. AI SDK v6 keeps thrown tool errors out of
|
|
705
|
+
// step.toolResults (they're `tool-error` parts in step.content), and pairing
|
|
706
|
+
// toolCalls[i] with toolResults[i] by index drifts as soon as one call throws.
|
|
707
|
+
function stepToolOutcomes(step) {
|
|
708
|
+
if (Array.isArray(step.content)) {
|
|
709
|
+
return step.content
|
|
710
|
+
.filter(p => p.type === 'tool-result' || p.type === 'tool-error')
|
|
711
|
+
.map(p => p.type === 'tool-error'
|
|
712
|
+
? {
|
|
713
|
+
toolName: p.toolName,
|
|
714
|
+
input: p.input,
|
|
715
|
+
output: String(p.error?.message ?? p.error ?? 'Tool failed'),
|
|
716
|
+
isError: true,
|
|
717
|
+
}
|
|
718
|
+
: {
|
|
719
|
+
toolName: p.toolName,
|
|
720
|
+
input: p.input,
|
|
721
|
+
output: p.output,
|
|
722
|
+
// MCP tools report failure as { isError: true } on their result.
|
|
723
|
+
isError: p.output?.isError === true,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
const callsById = new Map((step.toolCalls || []).map(tc => [tc.toolCallId, tc]));
|
|
727
|
+
return (step.toolResults || []).map(tr => ({
|
|
728
|
+
...tr,
|
|
729
|
+
input: callsById.get(tr.toolCallId)?.input ?? callsById.get(tr.toolCallId)?.args,
|
|
730
|
+
}));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// prepareStep hook: shortens older tool results once the conversation is large.
|
|
734
|
+
// Returns {} (no change) until then, so short runs are untouched.
|
|
735
|
+
function trimOldToolResults(messages) {
|
|
736
|
+
let size;
|
|
737
|
+
try { size = JSON.stringify(messages).length; } catch { return {}; }
|
|
738
|
+
if (size <= CONTEXT_TRIM_CHARS) return {};
|
|
739
|
+
|
|
740
|
+
const toolIdx = messages.flatMap((m, i) => (m.role === 'tool' ? [i] : []));
|
|
741
|
+
const trimUpTo = new Set(toolIdx.slice(0, -KEEP_RECENT_TOOL_MESSAGES));
|
|
742
|
+
if (trimUpTo.size === 0) return {};
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
messages: messages.map((m, i) => {
|
|
746
|
+
if (!trimUpTo.has(i) || !Array.isArray(m.content)) return m;
|
|
747
|
+
return {
|
|
748
|
+
...m,
|
|
749
|
+
content: m.content.map(part => {
|
|
750
|
+
if (part.type !== 'tool-result' || !part.output) return part;
|
|
751
|
+
const raw = typeof part.output.value === 'string'
|
|
752
|
+
? part.output.value
|
|
753
|
+
: JSON.stringify(part.output.value ?? part.output);
|
|
754
|
+
if (raw.length <= TRIMMED_RESULT_CHARS + 200) return part;
|
|
755
|
+
return {
|
|
756
|
+
...part,
|
|
757
|
+
output: {
|
|
758
|
+
type: String(part.output.type).startsWith('error') ? 'error-text' : 'text',
|
|
759
|
+
value:
|
|
760
|
+
`${raw.slice(0, TRIMMED_RESULT_CHARS)} … (earlier result shortened to keep room; ` +
|
|
761
|
+
'call the tool again if you need it in full)',
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
}),
|
|
765
|
+
};
|
|
766
|
+
}),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
623
770
|
// ── Helpers ─────────────────────────────────────────────
|
|
624
771
|
|
|
625
772
|
function logActivity(agentDir, entry) {
|
|
@@ -775,7 +922,7 @@ function isPausedResponse(err) {
|
|
|
775
922
|
}
|
|
776
923
|
|
|
777
924
|
class LeaseLostError extends Error {
|
|
778
|
-
constructor(message = '
|
|
925
|
+
constructor(message = 'Another copy of this agent took over (started on this computer or another one), so this copy stopped') {
|
|
779
926
|
super(message);
|
|
780
927
|
this.name = 'LeaseLostError';
|
|
781
928
|
this.code = 'LEASE_LOST';
|
|
@@ -171,7 +171,7 @@ async function walkLocalWizard(connector, baseAnswers) {
|
|
|
171
171
|
type: 'input',
|
|
172
172
|
name: 'command',
|
|
173
173
|
message: 'Command:',
|
|
174
|
-
default:
|
|
174
|
+
default: connector.command || 'npx',
|
|
175
175
|
validate: v => v.trim().length > 0 || 'command is required',
|
|
176
176
|
filter: v => v.trim(),
|
|
177
177
|
},
|
|
@@ -222,7 +222,7 @@ async function walkRemoteWizard(connector, baseAnswers) {
|
|
|
222
222
|
type: 'input',
|
|
223
223
|
name: 'url',
|
|
224
224
|
message: 'URL:',
|
|
225
|
-
default:
|
|
225
|
+
default: connector.url || '',
|
|
226
226
|
validate: v => /^https?:\/\//i.test((v || '').trim()) || 'must start with http:// or https://',
|
|
227
227
|
filter: v => v.trim(),
|
|
228
228
|
},
|
|
@@ -165,7 +165,7 @@ function generateAgentConfig({ name, displayName, description, checkInInterval,
|
|
|
165
165
|
brain: {
|
|
166
166
|
provider: provider || 'anthropic',
|
|
167
167
|
model: model || (provider === 'openai' ? 'gpt-4o' : 'claude-sonnet-4-5-20250929'),
|
|
168
|
-
max_tokens_per_loop:
|
|
168
|
+
max_tokens_per_loop: 16000,
|
|
169
169
|
fallback_provider: null,
|
|
170
170
|
},
|
|
171
171
|
|