@myvillage/cli 2.13.0 → 2.13.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "2.13.0",
3
+ "version": "2.13.2",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -112,15 +112,24 @@ export async function agentLoop(agentName, { signal }) {
112
112
  const anthropic = createAnthropic({ apiKey });
113
113
  model = anthropic(modelId);
114
114
  }
115
- const maxTokens = config.brain?.max_tokens_per_loop || 8000;
116
- // Max agentic steps (one model turn + its tool executions) per loop
117
- // iteration. A task like "enumerate N villagers' knowledge, then Slack each
118
- // lead" needs a step per discovery wave plus the action steps; 10 was too low
119
- // — the model would exhaust the budget mid-task (e.g. right after listing
120
- // Slack channels but before sending). Configurable per agent via
121
- // brain.max_steps_per_loop; default 25. Cost stays bounded by
122
- // max_tokens_per_loop and the rate limiter regardless of this ceiling.
123
- const maxSteps = config.brain?.max_steps_per_loop || 25;
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
- const taskActionAudit = {
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 (config.man?.village_agent_id) {
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
- logActivity(agentDir, { type: 'task_claimed', taskId: activeTask.id, taskType: activeTask.taskType });
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 give it up to `maxSteps` steps (default 25,
337
- // see above) so a multi-step task like "find the low-activity villagers,
338
- // then message each lead" can do discover → reason → act across enough
339
- // turns to actually finish, instead of stopping mid-task.
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
- { role: 'user', content: context },
448
+ ...conversation,
360
449
  ],
361
450
  tools,
362
- stopWhen: stepCountIs(maxSteps),
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, 32k chars holds a maxed-out
375
- // 8k-token reply, so the Logs panel no longer clips mid-sentence.
376
- text: (result.text || '').slice(0, 32000),
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('\n[MyVillage] Lease lost — another machine now owns this agent. Exiting.\n');
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
- // If a task was in flight when we crashed, mark it FAILED so it isn't lost
594
- if (activeTask && config.man?.village_agent_id) {
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
- if (intervalMs > 0 && !signal.aborted) {
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 = 'Lease lost — another machine now owns this agent') {
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';
@@ -24,10 +24,10 @@ import path from 'path';
24
24
  import { getConfig } from '../utils/config.js';
25
25
  import { getAccessToken, isTokenExpired } from '../utils/auth.js';
26
26
  import { refreshAccessToken } from '../utils/api.js';
27
- import { listLocalAgents, readAgentConfig, getAgentDir, writeAgentConfig } from '../utils/local-agent.js';
27
+ import { listLocalAgents, readAgentConfig, getAgentDir } from '../utils/local-agent.js';
28
28
  import { startDaemon, stopDaemon, isDaemonRunning, getDaemonInfo } from './daemon.js';
29
29
  import { shipLogsOnce, forgetLogCursor } from './log-shipper.js';
30
- import { writeSyncedModel } from '../utils/agent-models.js';
30
+ import { writePublishedAgentConfig, readSyncedVersion } from '../utils/agent-config-sync.js';
31
31
 
32
32
  const RUNNER_STATE_FILE = path.join(os.homedir(), '.myvillage', 'runner.json');
33
33
  const DEFAULT_HEARTBEAT_MS = 20_000;
@@ -128,20 +128,6 @@ async function request(networkBaseUrl, pathname, method, body, headers) {
128
128
 
129
129
  // ── Config sync (materialise an assigned agent from the control plane) ───────
130
130
 
131
- // Records which published config version this machine last wrote, so we only
132
- // re-write files when the definition actually changed.
133
- function syncedVersionPath(agentDir) {
134
- return path.join(agentDir, '.config-version');
135
- }
136
-
137
- function readSyncedVersion(agentDir) {
138
- try {
139
- return Number(fs.readFileSync(syncedVersionPath(agentDir), 'utf8').trim()) || 0;
140
- } catch {
141
- return 0;
142
- }
143
- }
144
-
145
131
  /**
146
132
  * Write an assigned agent's files from its published definition, creating the
147
133
  * agent directory if this machine has never run it. Returns true when the agent
@@ -167,53 +153,12 @@ async function syncAgentConfig(ctx, assignment) {
167
153
  log(`could not fetch config for ${name} (${res.status}) — leaving local files alone`);
168
154
  return exists;
169
155
  }
170
- const c = res.json.data;
171
-
172
- fs.mkdirSync(agentDir, { recursive: true });
173
- if (typeof c.promptMd === 'string' && c.promptMd) {
174
- fs.writeFileSync(path.join(agentDir, 'prompt.md'), c.promptMd);
175
- }
176
- if (typeof c.toolsYaml === 'string' && c.toolsYaml) {
177
- fs.writeFileSync(path.join(agentDir, 'tools.yaml'), c.toolsYaml);
178
- }
179
-
180
- // The trained models this agent was given travel with its config.
181
- if (c.models && typeof c.models === 'object') {
182
- for (const [slug, entry] of Object.entries(c.models)) {
183
- const result = writeSyncedModel(slug, entry);
184
- if (result === 'written') log(`synced model ${slug} for ${name}`);
185
- else if (result === 'kept-local') log(`kept this machine's own model ${slug} for ${name}`);
186
- else if (result === 'invalid') log(`skipped model ${slug} for ${name}: its files didn't check out`);
187
- }
156
+ const { models } = writePublishedAgentConfig(name, { ...res.json.data, version: configVersion });
157
+ for (const { slug, result } of models) {
158
+ if (result === 'written') log(`synced model ${slug} for ${name}`);
159
+ else if (result === 'kept-local') log(`kept this machine's own model ${slug} for ${name}`);
160
+ else if (result === 'invalid') log(`skipped model ${slug} for ${name}: its files didn't check out`);
188
161
  }
189
-
190
- // Compose agent.config.yaml from server-known identity + published schedule
191
- // and brain, preserving anything already set locally that the control plane
192
- // does not own.
193
- let existingCfg = {};
194
- try { existingCfg = readAgentConfig(name) || {}; } catch { /* first sync */ }
195
- const cfg = {
196
- ...existingCfg,
197
- name,
198
- display_name: c.displayName || existingCfg.display_name || name,
199
- description: c.description ?? existingCfg.description ?? '',
200
- man: {
201
- ...(existingCfg.man || {}),
202
- agent_id: c.agentId,
203
- ...(c.villageAgentId ? { village_agent_id: c.villageAgentId } : {}),
204
- handle: c.handle || name,
205
- },
206
- ...(c.schedule ? { schedule: c.schedule } : {}),
207
- ...(c.brain ? { brain: c.brain } : {}),
208
- };
209
- writeAgentConfig(name, cfg);
210
-
211
- // Keep an empty .env so the daemon's loader and 0600 expectations hold, but
212
- // never overwrite secrets a person put here.
213
- const envPath = path.join(agentDir, '.env');
214
- if (!fs.existsSync(envPath)) fs.writeFileSync(envPath, '', { mode: 0o600 });
215
-
216
- fs.writeFileSync(syncedVersionPath(agentDir), String(configVersion));
217
162
  log(`synced ${name} config v${configVersion}${exists ? '' : ' (new on this machine)'}`);
218
163
  return true;
219
164
  }
@@ -15,6 +15,7 @@ import {
15
15
  listCommunities,
16
16
  getAgentActivity,
17
17
  listMyAgents,
18
+ getAgentPublishedConfig,
18
19
  listMyUnlinkedAgentProfiles,
19
20
  createVillageAgent as apiCreateVillageAgent,
20
21
  listAgentTasks,
@@ -45,6 +46,7 @@ import {
45
46
  readAgentLogs,
46
47
  getLogFilePath,
47
48
  } from '../utils/local-agent.js';
49
+ import { writePublishedAgentConfig } from '../utils/agent-config-sync.js';
48
50
  import { scaffoldAgent, TOOL_CATALOG, resolveCatalogEntry } from '../utils/agent-scaffolder.js';
49
51
  import { loadAgentRecipe, listAgentRecipes } from '../utils/agent-recipes.js';
50
52
  import { formatLocalAgentList, formatLocalAgentStatus } from '../utils/formatters.js';
@@ -1315,12 +1317,12 @@ export async function agentRememberCommand(name, text, options = {}) {
1315
1317
  // already has the agent — this command scaffolds the missing local config
1316
1318
  // (~/.myvillage/agents/<handle>/) so `agent start` can launch the daemon.
1317
1319
  //
1318
- // What pulls down: the network identity (id, handle, displayName) and a
1319
- // link to the existing VillageAgent task queue.
1320
- // What doesn't: custom prompt edits, tools.yaml customizations, and any
1321
- // API keys. Those stay machine-local and you'll need to redo them if you
1322
- // want an exact replica. The scaffold defaults are a reasonable starting
1323
- // point that will boot.
1320
+ // What pulls down: the network identity (id, handle, displayName), a link to
1321
+ // the existing VillageAgent task queue, and — when the agent has a published
1322
+ // definition (Studio publishes on every save) — its prompt.md, tools.yaml,
1323
+ // schedule, model settings and trained models.
1324
+ // What doesn't: API keys and other .env secrets. Those stay machine-local.
1325
+ // An agent that was never published falls back to the scaffold defaults.
1324
1326
 
1325
1327
  async function fetchMyAgentByHandle(handle) {
1326
1328
  const result = await listMyAgents({ includeVillageAgent: true });
@@ -1365,8 +1367,17 @@ export async function agentPullCommand(handle, options = {}) {
1365
1367
  return;
1366
1368
  }
1367
1369
 
1368
- // Default scaffold: just the always-on MyVillage MCP tool. Users can add
1369
- // more via `agent add-tool` once they're up.
1370
+ // The published definition, if there is one. A failure here is not fatal:
1371
+ // the scaffold defaults still boot.
1372
+ let published = null;
1373
+ try {
1374
+ const c = await getAgentPublishedConfig(profile.id);
1375
+ if (c && c.version > 0) published = c;
1376
+ } catch { /* fall back to defaults */ }
1377
+
1378
+ // Scaffold first so the directory has everything `start` expects (routines/,
1379
+ // logs/, .env); the published definition is then written over the defaults.
1380
+ // Default scaffold: just the always-on MyVillage MCP tool.
1370
1381
  const agentDir = getAgentDir(agentName);
1371
1382
  try {
1372
1383
  scaffoldAgent(agentDir, {
@@ -1394,6 +1405,16 @@ export async function agentPullCommand(handle, options = {}) {
1394
1405
  }
1395
1406
  writeAgentConfig(agentName, cfg);
1396
1407
 
1408
+ let models = [];
1409
+ if (published) {
1410
+ try {
1411
+ ({ models } = writePublishedAgentConfig(agentName, published));
1412
+ } catch (err) {
1413
+ spinner.warn(`Pulled ${handle}, but its saved setup couldn't be written: ${err.message}`);
1414
+ published = null;
1415
+ }
1416
+ }
1417
+
1397
1418
  spinner.succeed(`Pulled ${handle} to ~/.myvillage/agents/${agentName}/`);
1398
1419
 
1399
1420
  console.log('');
@@ -1404,11 +1425,25 @@ export async function agentPullCommand(handle, options = {}) {
1404
1425
  } else {
1405
1426
  console.log(` - ${chalk.yellow('No VillageAgent shim found — one will be created on first start.')}`);
1406
1427
  }
1407
- console.log('');
1408
- console.log(brand.teal(' Defaults that may need tweaking:'));
1409
- console.log(` - ${brand.gold('prompt.md')} (system prompt — generic template)`);
1410
- console.log(` - ${brand.gold('tools.yaml')} (only the MyVillage tool is enabled)`);
1411
- console.log(` - ${brand.gold('agent.config.yaml')} (anthropic + claude-sonnet by default)`);
1428
+ if (published) {
1429
+ const brain = published.brain;
1430
+ console.log(` - Saved setup v${published.version}: ${published.promptMd ? 'prompt.md, ' : ''}${published.toolsYaml ? 'tools.yaml, ' : ''}schedule & model settings`);
1431
+ if (brain?.model) console.log(` - Model: ${brain.provider ? `${brain.provider} / ` : ''}${brain.model}`);
1432
+ const written = models.filter(m => m.result === 'written' || m.result === 'kept-local').map(m => m.slug);
1433
+ if (written.length) console.log(` - Trained models: ${written.join(', ')}`);
1434
+ const skipped = models.filter(m => m.result === 'invalid').map(m => m.slug);
1435
+ if (skipped.length) console.log(` - ${chalk.yellow(`Skipped models whose files didn't check out: ${skipped.join(', ')}`)}`);
1436
+ console.log('');
1437
+ console.log(brand.teal(' Not included:'));
1438
+ console.log(` - ${brand.gold('.env')} secrets (API keys etc.) stay on the machine they were set on.`);
1439
+ } else {
1440
+ console.log('');
1441
+ console.log(brand.teal(' No saved setup on the network yet, so these are defaults:'));
1442
+ console.log(` - ${brand.gold('prompt.md')} (system prompt — generic template)`);
1443
+ console.log(` - ${brand.gold('tools.yaml')} (only the MyVillage tool is enabled)`);
1444
+ console.log(` - ${brand.gold('agent.config.yaml')} (anthropic + claude-sonnet by default)`);
1445
+ console.log(` Open the agent in Studio and save once on the original machine to publish it.`);
1446
+ }
1412
1447
  console.log('');
1413
1448
  console.log(brand.teal(' Next:'));
1414
1449
  console.log(` ${brand.gold(`myvillage agent edit ${agentName}`)} Customize prompt / tools`);
@@ -171,7 +171,7 @@ async function walkLocalWizard(connector, baseAnswers) {
171
171
  type: 'input',
172
172
  name: 'command',
173
173
  message: 'Command:',
174
- default: recipe.command || 'npx',
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: recipe.url || '',
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
  },
@@ -0,0 +1,79 @@
1
+ // Write an agent's published definition (GET /agents/:id/config) to its local
2
+ // directory. Shared by the fleet runner, which keeps assigned agents in sync,
3
+ // and `agent pull`, which rehydrates an agent on a new machine.
4
+ //
5
+ // `.env` is never written from the server: secrets stay per-machine until they
6
+ // can be envelope-encrypted (see AgentConfig in the ed-platform schema).
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { readAgentConfig, getAgentDir, writeAgentConfig } from './local-agent.js';
11
+ import { writeSyncedModel } from './agent-models.js';
12
+
13
+ // Records which published config version this machine last wrote, so the
14
+ // runner only re-writes files when the definition actually changed.
15
+ export function syncedVersionPath(agentDir) {
16
+ return path.join(agentDir, '.config-version');
17
+ }
18
+
19
+ export function readSyncedVersion(agentDir) {
20
+ try {
21
+ return Number(fs.readFileSync(syncedVersionPath(agentDir), 'utf8').trim()) || 0;
22
+ } catch {
23
+ return 0;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Materialise a published config `c` into ~/.myvillage/agents/<name>/.
29
+ * Anything already set locally that the control plane does not own is kept.
30
+ * Returns { models: [{ slug, result }] } so callers can report what happened.
31
+ */
32
+ export function writePublishedAgentConfig(name, c) {
33
+ const agentDir = getAgentDir(name);
34
+ fs.mkdirSync(agentDir, { recursive: true });
35
+
36
+ if (typeof c.promptMd === 'string' && c.promptMd) {
37
+ fs.writeFileSync(path.join(agentDir, 'prompt.md'), c.promptMd);
38
+ }
39
+ if (typeof c.toolsYaml === 'string' && c.toolsYaml) {
40
+ fs.writeFileSync(path.join(agentDir, 'tools.yaml'), c.toolsYaml);
41
+ }
42
+
43
+ // The trained models this agent was given travel with its config.
44
+ const models = [];
45
+ if (c.models && typeof c.models === 'object') {
46
+ for (const [slug, entry] of Object.entries(c.models)) {
47
+ models.push({ slug, result: writeSyncedModel(slug, entry) });
48
+ }
49
+ }
50
+
51
+ // Compose agent.config.yaml from server-known identity + published schedule
52
+ // and brain, preserving anything already set locally that the control plane
53
+ // does not own.
54
+ let existingCfg = {};
55
+ try { existingCfg = readAgentConfig(name) || {}; } catch { /* first sync */ }
56
+ const cfg = {
57
+ ...existingCfg,
58
+ name,
59
+ display_name: c.displayName || existingCfg.display_name || name,
60
+ description: c.description ?? existingCfg.description ?? '',
61
+ man: {
62
+ ...(existingCfg.man || {}),
63
+ agent_id: c.agentId,
64
+ ...(c.villageAgentId ? { village_agent_id: c.villageAgentId } : {}),
65
+ handle: c.handle || name,
66
+ },
67
+ ...(c.schedule ? { schedule: c.schedule } : {}),
68
+ ...(c.brain ? { brain: c.brain } : {}),
69
+ };
70
+ writeAgentConfig(name, cfg);
71
+
72
+ // Keep an empty .env so the daemon's loader and 0600 expectations hold, but
73
+ // never overwrite secrets a person put here.
74
+ const envPath = path.join(agentDir, '.env');
75
+ if (!fs.existsSync(envPath)) fs.writeFileSync(envPath, '', { mode: 0o600 });
76
+
77
+ if (c.version) fs.writeFileSync(syncedVersionPath(agentDir), String(c.version));
78
+ return { models };
79
+ }
@@ -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: 8000,
168
+ max_tokens_per_loop: 16000,
169
169
  fallback_provider: null,
170
170
  },
171
171
 
package/src/utils/api.js CHANGED
@@ -302,6 +302,14 @@ export async function listMyAgents(options = {}) {
302
302
  return response.data;
303
303
  }
304
304
 
305
+ // The agent's published definition (prompt, tools, schedule, model settings,
306
+ // trained models). Studio republishes it on every save.
307
+ export async function getAgentPublishedConfig(id) {
308
+ const client = getNetworkClient();
309
+ const response = await client.get(`/agents/${id}/config`);
310
+ return response.data?.data ?? response.data;
311
+ }
312
+
305
313
  export async function getAgent(id) {
306
314
  const client = getNetworkClient();
307
315
  const response = await client.get(`/agents/${id}`);