@myvillage/cli 2.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "2.12.0",
3
+ "version": "2.13.1",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -73,8 +73,19 @@ export const BUILTIN_TOOL_DEFS = [
73
73
  },
74
74
  ];
75
75
 
76
- /** One line telling an agent what a local model is for and what it takes. */
77
- export function describeLocalModel(m) {
76
+ /** Longest example input shown to an agent, so a model's description stays short. */
77
+ const MAX_EXAMPLE_CHARS = 400;
78
+
79
+ /**
80
+ * Tell an agent what a local model is for, what it takes, and what it gives
81
+ * back, so it can decide whether the model fits the job.
82
+ *
83
+ * `extras` come from the dataset the model learned from: `labelNames` (the
84
+ * villager's names for each label id, e.g. waiting → "Spam and Junk"),
85
+ * `learnedFrom` (the dataset's description) and `example` (one training input,
86
+ * so the agent sends text shaped the same way).
87
+ */
88
+ export function describeLocalModel(m, extras = {}) {
78
89
  const f = m.featurization ?? {};
79
90
  const modality = f.modality ?? m.modality;
80
91
  let takes;
@@ -88,9 +99,18 @@ export function describeLocalModel(m) {
88
99
  } else {
89
100
  takes = `a list of ${m.inputDim ?? 'some'} numbers`;
90
101
  }
102
+ const names = extras.labelNames ?? {};
103
+ const label = (l) => (names[l] && names[l] !== l ? `"${l}" (${names[l]})` : `"${l}"`);
91
104
  const gives =
92
105
  m.taskType === 'classification' && Array.isArray(m.labels) && m.labels.length
93
- ? `sorts it into: ${m.labels.join(', ')}`
106
+ ? `sorts it into: ${m.labels.map(label).join(', ')}`
94
107
  : 'gives back a number';
95
- return `- "${m.slug}" (${m.name}): takes ${takes}; ${gives}.`;
108
+ const lines = [`- "${m.slug}" (${m.name}): takes ${takes}; ${gives}.`];
109
+ if (extras.learnedFrom) lines.push(` Learned from: ${extras.learnedFrom}`);
110
+ if (extras.example != null && modality !== 'image' && modality !== 'audio') {
111
+ let ex = typeof extras.example === 'string' ? extras.example : JSON.stringify(extras.example);
112
+ if (ex.length > MAX_EXAMPLE_CHARS) ex = ex.slice(0, MAX_EXAMPLE_CHARS) + '…';
113
+ lines.push(` Send input shaped like this training example: ${JSON.stringify(ex)}`);
114
+ }
115
+ return lines.join('\n');
96
116
  }
@@ -37,10 +37,8 @@ import {
37
37
  DATASET_READ_DESCRIPTION,
38
38
  DATASET_SPLIT_DESCRIPTION,
39
39
  MODEL_PREDICT_DESCRIPTION,
40
- describeLocalModel,
41
40
  } from './builtin-tools-registry.js';
42
- import { readAgentModels } from '../utils/agent-models.js';
43
- import { readModelManifest as readModelManifestSync } from '../utils/model-workspace.js';
41
+ import { readAgentModels, describeAgentModel, modelDatasetExtras } from '../utils/agent-models.js';
44
42
  import { readToolsYaml } from '../utils/local-agent.js';
45
43
 
46
44
  export { BUILTIN_TOOL_DEFS } from './builtin-tools-registry.js';
@@ -106,11 +104,8 @@ function applyBuiltinAllowlist(builtins, agentName) {
106
104
  }
107
105
 
108
106
  /** model_predict's description, naming the models this agent was given. */
109
- function modelPredictDescription(slugs, readModelManifest) {
110
- const lines = slugs.map((slug) => {
111
- const m = readModelManifest(slug);
112
- return m ? describeLocalModel(m) : `- "${slug}": not on this computer yet.`;
113
- });
107
+ function modelPredictDescription(slugs) {
108
+ const lines = slugs.map(describeAgentModel);
114
109
  return `${MODEL_PREDICT_DESCRIPTION} Use one whenever its job matches what you are doing. The models you can use (the quoted slug is the modelId):\n${lines.join('\n')}`;
115
110
  }
116
111
 
@@ -292,7 +287,7 @@ export function getBuiltinTools(agentName, config) {
292
287
  }),
293
288
 
294
289
  model_predict: tool({
295
- description: modelPredictDescription(grantedModels, readModelManifestSync),
290
+ description: modelPredictDescription(grantedModels),
296
291
  inputSchema: z.object({
297
292
  modelId: z.string().describe('Slug of the local trained model'),
298
293
  input: z
@@ -331,7 +326,10 @@ export function getBuiltinTools(agentName, config) {
331
326
  const { predictWithModel } = await import('../utils/model-trainer.js');
332
327
  const record = readModelManifest(modelId);
333
328
  const model = await loadModel(modelId);
334
- return predictWithModel(model, record, input);
329
+ const result = await predictWithModel(model, record, input);
330
+ // Name the label the way the villager named it (e.g. "Spam and Junk").
331
+ const name = result?.label != null ? modelDatasetExtras(record).labelNames?.[result.label] : undefined;
332
+ return name && name !== result.label ? { ...result, labelName: name } : result;
335
333
  } catch (e) {
336
334
  return { error: e.message };
337
335
  }
@@ -13,8 +13,7 @@
13
13
 
14
14
  import { listDatasets } from '../utils/dataset-workspace.js';
15
15
  import { listModels } from '../utils/model-workspace.js';
16
- import { describeLocalModel } from './builtin-tools-registry.js';
17
- import { readAgentModels } from '../utils/agent-models.js';
16
+ import { readAgentModels, describeAgentModel } from '../utils/agent-models.js';
18
17
 
19
18
  export async function gatherContext(agentConfig, lastCheckIn, recentActions = [], agentName = agentConfig?.name) {
20
19
  const parts = [];
@@ -57,7 +56,7 @@ export async function gatherContext(agentConfig, lastCheckIn, recentActions = []
57
56
  const models = listModels().filter((m) => granted.has(m.slug));
58
57
  if (models.length > 0) {
59
58
  parts.push('\nModels you can run with model_predict (the quoted slug is the modelId). Use one whenever its job matches what you are doing:');
60
- for (const m of models.slice(0, 25)) parts.push(describeLocalModel(m));
59
+ for (const m of models.slice(0, 25)) parts.push(describeAgentModel(m.slug));
61
60
  }
62
61
  } catch {
63
62
  /* no local models / store unavailable — skip */
@@ -10,6 +10,7 @@ import { join } from 'path';
10
10
  import { homedir } from 'os';
11
11
  import { getMCPTools, cleanupMCPClients } from './mcp-client.js';
12
12
  import { getBuiltinTools } from './builtin-tools.js';
13
+ import { readAgentModels } from '../utils/agent-models.js';
13
14
  import { gatherContext } from './context.js';
14
15
  import { isWithinActiveHours, getNextCheckInMs } from './scheduler.js';
15
16
  import { parse as parseYaml } from 'yaml';
@@ -111,15 +112,24 @@ export async function agentLoop(agentName, { signal }) {
111
112
  const anthropic = createAnthropic({ apiKey });
112
113
  model = anthropic(modelId);
113
114
  }
114
- const maxTokens = config.brain?.max_tokens_per_loop || 8000;
115
- // Max agentic steps (one model turn + its tool executions) per loop
116
- // iteration. A task like "enumerate N villagers' knowledge, then Slack each
117
- // lead" needs a step per discovery wave plus the action steps; 10 was too low
118
- // — the model would exhaust the budget mid-task (e.g. right after listing
119
- // Slack channels but before sending). Configurable per agent via
120
- // brain.max_steps_per_loop; default 25. Cost stays bounded by
121
- // max_tokens_per_loop and the rate limiter regardless of this ceiling.
122
- 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));
123
133
 
124
134
  // Refresh BEFORE the first MCP connect. Whoever spawned us put a snapshot of
125
135
  // MYVILLAGE_ACCESS_TOKEN in our env, and the MCP client bakes it into its
@@ -140,7 +150,8 @@ export async function agentLoop(agentName, { signal }) {
140
150
  const result = await getMCPTools(agentDir, config);
141
151
  tools = result.tools;
142
152
  // Merge native built-in tools (in-process; no MCP server). e.g. dataset_append.
143
- Object.assign(tools, getBuiltinTools(agentName, config));
153
+ const builtins = getBuiltinTools(agentName, config);
154
+ Object.assign(tools, builtins);
144
155
  const servers = result.servers;
145
156
  const totalTools = Object.keys(tools).length;
146
157
  const failed = servers.filter(s => s.status === 'failed');
@@ -150,6 +161,9 @@ export async function agentLoop(agentName, { signal }) {
150
161
  model: modelId,
151
162
  totalTools,
152
163
  servers,
164
+ // Named so the log shows what the agent could use, models included.
165
+ builtinTools: Object.keys(builtins),
166
+ models: readAgentModels(agentName),
153
167
  });
154
168
 
155
169
  // Surface failed MCP connections as an error event too, so they show up
@@ -168,6 +182,11 @@ export async function agentLoop(agentName, { signal }) {
168
182
  let lastCheckIn = null;
169
183
  let iteration = 0;
170
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;
171
190
 
172
191
  while (!signal.aborted) {
173
192
  // Check active hours
@@ -176,9 +195,10 @@ export async function agentLoop(agentName, { signal }) {
176
195
  continue;
177
196
  }
178
197
 
179
- // 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.
180
200
  const intervalMs = getNextCheckInMs(config.schedule);
181
- if (intervalMs === 0 && iteration > 0) {
201
+ if (intervalMs === 0 && iteration > 0 && !resume) {
182
202
  break;
183
203
  }
184
204
 
@@ -238,11 +258,23 @@ export async function agentLoop(agentName, { signal }) {
238
258
  // Tracks whether the task's tool calls actually succeeded. The LLM
239
259
  // sometimes "summarizes" a tool error into a falsely-confident final
240
260
  // response — we don't want to trust the model's word about success.
241
- 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 || {
242
264
  actionToolsCalled: 0,
243
265
  actionToolsSucceeded: 0,
244
266
  toolErrors: [], // { tool, message }
245
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 || [];
246
278
 
247
279
  try {
248
280
  // Read prompt.md fresh each iteration (villager may have edited it).
@@ -278,7 +310,9 @@ export async function agentLoop(agentName, { signal }) {
278
310
 
279
311
  // Try to pull an assigned task first. Tasks take priority over ambient
280
312
  // feed-monitoring. If nothing is queued, fall through to the default.
281
- if (config.man?.village_agent_id) {
313
+ if (resuming) {
314
+ activeTask = resuming.task;
315
+ } else if (config.man?.village_agent_id) {
282
316
  activeTask = await pollAndClaim(config.man.village_agent_id, agentDir);
283
317
  }
284
318
 
@@ -311,7 +345,11 @@ Guidelines:
311
345
 
312
346
  context = `## TASK (id=${activeTask.id}, type=${activeTask.taskType})\n${instructionText}\n\n---\n\n## AMBIENT CONTEXT (for awareness only)\n${context}`;
313
347
 
314
- 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
+ }
315
353
  }
316
354
 
317
355
  // Feed is no longer pushed into context; nothing to count here.
@@ -324,14 +362,69 @@ Guidelines:
324
362
  events: eventsFound,
325
363
  });
326
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
+
327
411
  // Call LLM with tools
328
412
  // AI SDK v6 removed `maxSteps` — the loop now uses `stopWhen` with
329
413
  // a StopCondition predicate. The default is `stepCountIs(1)`, so
330
414
  // without this the model gets exactly ONE round of tool calls and
331
- // returns immediately. We give it up to `maxSteps` steps (default 25,
332
- // see above) so a multi-step task like "find the low-activity villagers,
333
- // then message each lead" can do discover → reason → act across enough
334
- // 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.
335
428
  //
336
429
  // Anthropic prompt caching: we mark the system block as ephemeral so
337
430
  // everything before it (tool schemas + system prompt) is cached.
@@ -341,6 +434,7 @@ Guidelines:
341
434
  // The breakpoint is namespaced under `anthropic`, so OpenAI calls
342
435
  // silently ignore it. Cache TTL is ~5 min; agents that loop more
343
436
  // often than that stay warm and pay full price only on the first hit.
437
+ runMessages = conversation;
344
438
  const result = await generateText({
345
439
  model,
346
440
  messages: [
@@ -351,12 +445,30 @@ Guidelines:
351
445
  anthropic: { cacheControl: { type: 'ephemeral' } },
352
446
  },
353
447
  },
354
- { role: 'user', content: context },
448
+ ...conversation,
355
449
  ],
356
450
  tools,
357
- stopWhen: stepCountIs(maxSteps),
451
+ stopWhen: stopConditions,
358
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
+ },
359
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
+ }
360
472
 
361
473
  // Log LLM response. Cache metrics come from the Anthropic provider
362
474
  // metadata — surfacing them lets `agent logs` show whether caching is
@@ -366,9 +478,9 @@ Guidelines:
366
478
  const anthropicMeta = result.providerMetadata?.anthropic || {};
367
479
  logActivity(agentDir, {
368
480
  type: 'llm_response',
369
- // Keep the full response: at ~4 chars/token, 32k chars holds a maxed-out
370
- // 8k-token reply, so the Logs panel no longer clips mid-sentence.
371
- 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),
372
484
  tokensUsed: {
373
485
  prompt: result.usage?.inputTokens || 0,
374
486
  completion: result.usage?.outputTokens || 0,
@@ -378,106 +490,6 @@ Guidelines:
378
490
  },
379
491
  });
380
492
 
381
- // Log tool calls and count activity. Also audit action-tool success
382
- // so we don't trust the model's final text about whether a task worked.
383
- // `toolCallSummary` accumulates a structured per-call record that we
384
- // attach to the task output so the Activity panel can show what the
385
- // agent actually did — not just a tool-call count.
386
- const toolCallSummary = [];
387
- if (result.steps?.length) {
388
- for (const step of result.steps) {
389
- if (step.toolCalls?.length) {
390
- for (const tc of step.toolCalls) {
391
- activity.toolCalls++;
392
- if (tc.toolName === 'post_create') activity.postsCreated++;
393
- if (tc.toolName === 'comment_create') activity.commentsCreated++;
394
- if (tc.toolName === 'vote_cast') activity.votesGiven++;
395
- }
396
- }
397
- if (step.toolCalls?.length && step.toolResults?.length) {
398
- for (let i = 0; i < step.toolResults.length; i++) {
399
- const tr = step.toolResults[i];
400
- // AI SDK v6 renamed `args` → `input`. Try v6 first.
401
- const args = step.toolCalls[i]?.input ?? step.toolCalls[i]?.args;
402
- const errored = isToolResultError(tr);
403
- const resultSummary = summarizeToolResult(tr);
404
- auditToolCall(taskActionAudit, tr.toolName, errored, tr);
405
- toolCallSummary.push({
406
- tool: tr.toolName,
407
- args,
408
- result: resultSummary,
409
- ok: !errored,
410
- });
411
- logActivity(agentDir, {
412
- type: 'tool_call',
413
- tool: tr.toolName,
414
- args,
415
- result: resultSummary,
416
- ok: !errored,
417
- });
418
- }
419
- } else if (step.toolResults?.length) {
420
- for (const tr of step.toolResults) {
421
- const errored = isToolResultError(tr);
422
- const resultSummary = summarizeToolResult(tr);
423
- auditToolCall(taskActionAudit, tr.toolName, errored, tr);
424
- toolCallSummary.push({
425
- tool: tr.toolName,
426
- result: resultSummary,
427
- ok: !errored,
428
- });
429
- logActivity(agentDir, {
430
- type: 'tool_call',
431
- tool: tr.toolName,
432
- result: resultSummary,
433
- ok: !errored,
434
- });
435
- }
436
- }
437
- }
438
- } else if (result.toolCalls?.length) {
439
- for (const tc of result.toolCalls) {
440
- activity.toolCalls++;
441
- if (tc.toolName === 'post_create') activity.postsCreated++;
442
- if (tc.toolName === 'comment_create') activity.commentsCreated++;
443
- if (tc.toolName === 'vote_cast') activity.votesGiven++;
444
- // No paired result here — assume executed, can't audit.
445
- const args = tc.input ?? tc.args;
446
- toolCallSummary.push({
447
- tool: tc.toolName,
448
- args,
449
- result: 'executed',
450
- });
451
- logActivity(agentDir, {
452
- type: 'tool_call',
453
- tool: tc.toolName,
454
- args,
455
- result: 'executed',
456
- });
457
- }
458
- }
459
-
460
- // Record actions for dedup in future iterations
461
- const collectActions = (toolName, args) => {
462
- if (toolName === 'comment_create' && args?.postId) {
463
- recentActions.push({ type: 'comment', postId: args.postId, ts: new Date().toISOString() });
464
- } else if (toolName === 'post_create' && args?.communitySlug) {
465
- recentActions.push({ type: 'post', community: args.communitySlug, ts: new Date().toISOString() });
466
- } else if (toolName === 'vote_cast' && args?.targetId) {
467
- recentActions.push({ type: 'vote', targetId: args.targetId, targetType: args.targetType, ts: new Date().toISOString() });
468
- }
469
- };
470
- if (result.steps?.length) {
471
- for (const step of result.steps) {
472
- for (const tc of step.toolCalls || []) {
473
- collectActions(tc.toolName, tc.input ?? tc.args);
474
- }
475
- }
476
- } else if (result.toolCalls?.length) {
477
- for (const tc of result.toolCalls) {
478
- collectActions(tc.toolName, tc.input ?? tc.args);
479
- }
480
- }
481
493
  // Keep only last 50 actions to bound memory
482
494
  if (recentActions.length > 50) recentActions.splice(0, recentActions.length - 50);
483
495
 
@@ -518,8 +530,8 @@ Guidelines:
518
530
  toolErrors: taskActionAudit.toolErrors,
519
531
  note: 'Marked FAILED because the action tools did not succeed. The model\'s text may claim success but the underlying tool calls errored.',
520
532
  },
521
- tokensUsed: result.usage?.totalTokens || 0,
522
- durationMs: Date.now() - loopStart,
533
+ tokensUsed: (resuming?.tokens || 0) + (result.usage?.totalTokens || 0),
534
+ durationMs: Date.now() - (resuming?.startedAt || loopStart),
523
535
  });
524
536
  logActivity(agentDir, {
525
537
  type: 'task_failed',
@@ -534,8 +546,8 @@ Guidelines:
534
546
  toolCalls: toolCallSummary,
535
547
  toolErrors: taskActionAudit.toolErrors.length > 0 ? taskActionAudit.toolErrors : undefined,
536
548
  },
537
- tokensUsed: result.usage?.totalTokens || 0,
538
- durationMs: Date.now() - loopStart,
549
+ tokensUsed: (resuming?.tokens || 0) + (result.usage?.totalTokens || 0),
550
+ durationMs: Date.now() - (resuming?.startedAt || loopStart),
539
551
  });
540
552
  logActivity(agentDir, { type: 'task_completed', taskId: activeTask.id });
541
553
  }
@@ -573,7 +585,7 @@ Guidelines:
573
585
  } catch (err) {
574
586
  if (err instanceof LeaseLostError) {
575
587
  logActivity(agentDir, { type: 'lease_lost', message: err.message });
576
- console.log('\n[MyVillage] Lease lost — another machine now owns this agent. Exiting.\n');
588
+ console.log(`\n[MyVillage] ${err.message}.\n`);
577
589
  break;
578
590
  }
579
591
  if (err instanceof AgentPausedError) {
@@ -585,12 +597,41 @@ Guidelines:
585
597
  type: 'error',
586
598
  error: err.message,
587
599
  });
588
- // If a task was in flight when we crashed, mark it FAILED so it isn't lost
589
- 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) {
590
631
  try {
591
632
  await completeAgentTask(config.man.village_agent_id, activeTask.id, {
592
- errorMessage: err.message,
593
- durationMs: Date.now() - loopStart,
633
+ errorMessage: signal.aborted ? 'Stopped before the task finished' : err.message,
634
+ durationMs: Date.now() - (resuming?.startedAt || loopStart),
594
635
  });
595
636
  logActivity(agentDir, { type: 'task_failed', taskId: activeTask.id });
596
637
  } catch {
@@ -605,8 +646,11 @@ Guidelines:
605
646
  logActivity(agentDir, { type: 'loop_end', iteration, duration_ms: duration });
606
647
  updateHeartbeat(agentDir);
607
648
 
608
- // Sleep until next interval
609
- 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) {
610
654
  await sleep(intervalMs, signal);
611
655
  }
612
656
  }
@@ -615,6 +659,114 @@ Guidelines:
615
659
  await cleanupMCPClients();
616
660
  }
617
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
+
618
770
  // ── Helpers ─────────────────────────────────────────────
619
771
 
620
772
  function logActivity(agentDir, entry) {
@@ -770,7 +922,7 @@ function isPausedResponse(err) {
770
922
  }
771
923
 
772
924
  class LeaseLostError extends Error {
773
- 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') {
774
926
  super(message);
775
927
  this.name = 'LeaseLostError';
776
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: 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
  },
@@ -16,10 +16,13 @@ import { createHash } from 'crypto';
16
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
17
17
  import { join } from 'path';
18
18
  import { readToolsYaml } from './local-agent.js';
19
- import { getModelDir, modelExists } from './model-workspace.js';
19
+ import { getModelDir, modelExists, readModelManifest } from './model-workspace.js';
20
+ import { readManifest as readDatasetManifest, readRows } from './dataset-workspace.js';
21
+ import { describeLocalModel } from '../agent-runtime/builtin-tools-registry.js';
20
22
 
21
23
  const MODEL_FILES = ['manifest.json', 'model.json', 'weights.b64'];
22
24
  const SYNCED_MARKER = '.synced-hash';
25
+ const ABOUT_FILE = 'about.json';
23
26
  const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,127}$/;
24
27
 
25
28
  /** Slugs listed under `models:` in an agent's tools.yaml (deduped, in order). */
@@ -46,7 +49,9 @@ export function bundleAgentModels(agentName) {
46
49
  const dir = getModelDir(slug);
47
50
  try {
48
51
  const files = Object.fromEntries(MODEL_FILES.map((f) => [f, readFileSync(join(dir, f), 'utf-8')]));
49
- out[slug] = { hash: hashFiles(files), files };
52
+ const manifest = JSON.parse(files['manifest.json']);
53
+ // What the dataset says about the model, since its dataset stays here.
54
+ out[slug] = { hash: hashFiles(files), files, about: modelDatasetExtras(manifest) };
50
55
  } catch {
51
56
  /* half-written or partly deleted model: skip it */
52
57
  }
@@ -75,7 +80,10 @@ export function writeSyncedModel(slug, entry) {
75
80
  const markerPath = join(dir, SYNCED_MARKER);
76
81
  if (modelExists(slug)) {
77
82
  if (!existsSync(markerPath)) return 'kept-local';
78
- if (readFileSync(markerPath, 'utf-8').trim() === hash) return 'current';
83
+ if (readFileSync(markerPath, 'utf-8').trim() === hash) {
84
+ writeAbout(dir, entry.about); // label names can change without retraining
85
+ return 'current';
86
+ }
79
87
  }
80
88
  mkdirSync(dir, { recursive: true });
81
89
  // Weights first and the manifest last: a model counts as present once its
@@ -84,5 +92,50 @@ export function writeSyncedModel(slug, entry) {
84
92
  writeFileSync(join(dir, 'model.json'), files['model.json']);
85
93
  writeFileSync(join(dir, 'manifest.json'), files['manifest.json']);
86
94
  writeFileSync(markerPath, hash);
95
+ writeAbout(dir, entry.about);
87
96
  return 'written';
88
97
  }
98
+
99
+ /** The synced copy of what a model's dataset says about it (see modelDatasetExtras). */
100
+ function writeAbout(dir, about) {
101
+ if (about && typeof about === 'object') writeFileSync(join(dir, ABOUT_FILE), JSON.stringify(about));
102
+ }
103
+
104
+ /**
105
+ * What the model's dataset adds to its description: the villager's names for
106
+ * its labels, what the dataset is, and one training input as an example.
107
+ * Falls back to the copy synced with the model, else empty; the model still
108
+ * works without it.
109
+ */
110
+ export function modelDatasetExtras(m) {
111
+ try {
112
+ const ds = m.datasetSlug ? readDatasetManifest(m.datasetSlug) : null;
113
+ if (!ds) return syncedAbout(m.slug);
114
+ let example;
115
+ try {
116
+ example = readRows(m.datasetSlug, { split: 'train', limit: 1 })[0]?.features;
117
+ } catch {
118
+ /* no rows to show */
119
+ }
120
+ return { labelNames: ds.labelNames ?? undefined, learnedFrom: ds.description || undefined, example };
121
+ } catch {
122
+ return {};
123
+ }
124
+ }
125
+
126
+ /** On a machine without the dataset: what came with the synced model. */
127
+ function syncedAbout(slug) {
128
+ try {
129
+ const about = JSON.parse(readFileSync(join(getModelDir(slug), ABOUT_FILE), 'utf-8'));
130
+ return about && typeof about === 'object' ? about : {};
131
+ } catch {
132
+ return {};
133
+ }
134
+ }
135
+
136
+ /** One model as an agent should see it (see describeLocalModel). */
137
+ export function describeAgentModel(slug) {
138
+ const m = readModelManifest(slug);
139
+ if (!m) return `- "${slug}": not on this computer yet.`;
140
+ return describeLocalModel(m, modelDatasetExtras(m));
141
+ }
@@ -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