@myvillage/cli 1.30.0 → 1.32.0
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 +69 -8
- package/src/utils/api.js +1 -1
package/package.json
CHANGED
|
@@ -12,7 +12,8 @@ import { getMCPTools, cleanupMCPClients } from './mcp-client.js';
|
|
|
12
12
|
import { gatherContext } from './context.js';
|
|
13
13
|
import { isWithinActiveHours, getNextCheckInMs } from './scheduler.js';
|
|
14
14
|
import { parse as parseYaml } from 'yaml';
|
|
15
|
-
import { postAgentHeartbeat, listAgentTasks, claimAgentTask, completeAgentTask } from '../utils/api.js';
|
|
15
|
+
import { postAgentHeartbeat, listAgentTasks, claimAgentTask, completeAgentTask, refreshAccessToken } from '../utils/api.js';
|
|
16
|
+
import { getAccessToken, isTokenExpired } from '../utils/auth.js';
|
|
16
17
|
import { readAgentWisdom } from '../utils/wisdom.js';
|
|
17
18
|
|
|
18
19
|
export async function agentLoop(agentName, { signal }) {
|
|
@@ -115,6 +116,29 @@ export async function agentLoop(agentName, { signal }) {
|
|
|
115
116
|
logActivity(agentDir, { type: 'loop_start', iteration });
|
|
116
117
|
updateHeartbeat(agentDir);
|
|
117
118
|
|
|
119
|
+
// Refresh OAuth token and reconnect MCP if the access token rotated.
|
|
120
|
+
// Why: the daemon is started with a snapshot of MYVILLAGE_ACCESS_TOKEN in
|
|
121
|
+
// env (see agent-local.js). The MCP client baked that token into its
|
|
122
|
+
// Authorization header at construction time and has no refresh path of
|
|
123
|
+
// its own — so once the OAuth token expires (~1h) every MCP tool call
|
|
124
|
+
// 401s until the daemon is restarted. Re-read credentials each loop and
|
|
125
|
+
// swap the MCP client when the token changes.
|
|
126
|
+
try {
|
|
127
|
+
if (isTokenExpired()) {
|
|
128
|
+
await refreshAccessToken();
|
|
129
|
+
}
|
|
130
|
+
const currentToken = getAccessToken();
|
|
131
|
+
if (currentToken && currentToken !== process.env.MYVILLAGE_ACCESS_TOKEN) {
|
|
132
|
+
process.env.MYVILLAGE_ACCESS_TOKEN = currentToken;
|
|
133
|
+
await cleanupMCPClients();
|
|
134
|
+
const refreshed = await getMCPTools(agentDir, config);
|
|
135
|
+
tools = refreshed.tools;
|
|
136
|
+
logActivity(agentDir, { type: 'mcp_reconnected', reason: 'token rotated' });
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logActivity(agentDir, { type: 'error', error: `Token refresh / MCP reconnect failed: ${err.message}` });
|
|
140
|
+
}
|
|
141
|
+
|
|
118
142
|
// Activity counters for this iteration
|
|
119
143
|
const activity = {
|
|
120
144
|
postsCreated: 0,
|
|
@@ -335,16 +359,30 @@ Guidelines:
|
|
|
335
359
|
// whether the action tools actually succeeded — not on the model's
|
|
336
360
|
// self-report. The LLM sometimes claims "I posted!" after a tool error.
|
|
337
361
|
if (activeTask && config.man?.village_agent_id) {
|
|
362
|
+
// Three independent failure signals:
|
|
363
|
+
// 1. action tools tried and all failed (original signal — still right)
|
|
364
|
+
// 2. any tool errored AND no action tool succeeded (e.g. an
|
|
365
|
+
// MCP/auth error during discovery means the LLM never got to
|
|
366
|
+
// call an action tool — older versions marked these COMPLETED)
|
|
367
|
+
// 3. the LLM explicitly declared failure in its final text
|
|
368
|
+
// (matches the `**Task Failed:` sentinel the model emits when
|
|
369
|
+
// it gives up after repeated tool errors)
|
|
370
|
+
const llmDeclaredFailure = /^\s*\*\*Task Failed/i.test(result.text || '');
|
|
371
|
+
const hasToolErrors = taskActionAudit.toolErrors.length > 0;
|
|
338
372
|
const shouldFail =
|
|
339
|
-
taskActionAudit.actionToolsCalled > 0 &&
|
|
340
|
-
taskActionAudit.actionToolsSucceeded === 0
|
|
373
|
+
(taskActionAudit.actionToolsCalled > 0 && taskActionAudit.actionToolsSucceeded === 0) ||
|
|
374
|
+
(hasToolErrors && taskActionAudit.actionToolsSucceeded === 0) ||
|
|
375
|
+
llmDeclaredFailure;
|
|
341
376
|
|
|
342
377
|
try {
|
|
343
378
|
if (shouldFail) {
|
|
344
379
|
const firstError = taskActionAudit.toolErrors[0];
|
|
380
|
+
const firstLine = (result.text || '').split('\n').find(l => l.trim()) || '';
|
|
345
381
|
const errorMessage = firstError
|
|
346
382
|
? `${firstError.tool} failed: ${firstError.message}`
|
|
347
|
-
:
|
|
383
|
+
: llmDeclaredFailure
|
|
384
|
+
? firstLine.replace(/^\*\*|\*\*$/g, '').slice(0, 300) || 'Agent declared task failed'
|
|
385
|
+
: 'Action tools called but all failed';
|
|
348
386
|
await completeAgentTask(config.man.village_agent_id, activeTask.id, {
|
|
349
387
|
errorMessage,
|
|
350
388
|
output: {
|
|
@@ -393,11 +431,17 @@ Guidelines:
|
|
|
393
431
|
durationMs: Date.now() - loopStart,
|
|
394
432
|
activitySummary: { feedItemsRead, mentionsFound },
|
|
395
433
|
});
|
|
396
|
-
} catch {
|
|
434
|
+
} catch (err) {
|
|
435
|
+
if (isPausedResponse(err)) throw new AgentPausedError();
|
|
397
436
|
logActivity(agentDir, { type: 'error', error: 'Failed to send server heartbeat' });
|
|
398
437
|
}
|
|
399
438
|
}
|
|
400
439
|
} catch (err) {
|
|
440
|
+
if (err instanceof AgentPausedError) {
|
|
441
|
+
logActivity(agentDir, { type: 'agent_paused', message: err.message });
|
|
442
|
+
console.log('\n[MyVillage] This agent has been paused by an admin. Exiting.\n');
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
401
445
|
logActivity(agentDir, {
|
|
402
446
|
type: 'error',
|
|
403
447
|
error: err.message,
|
|
@@ -545,9 +589,23 @@ function summarizeToolResult(tr) {
|
|
|
545
589
|
return text.slice(0, 200);
|
|
546
590
|
}
|
|
547
591
|
|
|
592
|
+
class AgentPausedError extends Error {
|
|
593
|
+
constructor(message = 'Agent is paused by an admin') {
|
|
594
|
+
super(message);
|
|
595
|
+
this.name = 'AgentPausedError';
|
|
596
|
+
this.code = 'AGENT_PAUSED';
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function isPausedResponse(err) {
|
|
601
|
+
return err?.response?.status === 409 && err?.response?.data?.code === 'AGENT_PAUSED';
|
|
602
|
+
}
|
|
603
|
+
|
|
548
604
|
// Pull up to 5 pending tasks and claim the first one we can win the race for.
|
|
549
605
|
// Returns the claimed task or null. Errors are swallowed and logged — the loop
|
|
550
|
-
// should keep running on transient backend issues.
|
|
606
|
+
// should keep running on transient backend issues. A pause response (409
|
|
607
|
+
// AGENT_PAUSED) is the one exception: rethrown as AgentPausedError so the
|
|
608
|
+
// outer loop can shut down cleanly.
|
|
551
609
|
async function pollAndClaim(villageAgentId, agentDir) {
|
|
552
610
|
try {
|
|
553
611
|
const result = await listAgentTasks(villageAgentId, { status: 'PENDING', limit: 5 });
|
|
@@ -557,12 +615,15 @@ async function pollAndClaim(villageAgentId, agentDir) {
|
|
|
557
615
|
try {
|
|
558
616
|
const claim = await claimAgentTask(villageAgentId, task.id);
|
|
559
617
|
return claim.data || task;
|
|
560
|
-
} catch {
|
|
561
|
-
|
|
618
|
+
} catch (err) {
|
|
619
|
+
if (isPausedResponse(err)) throw new AgentPausedError();
|
|
620
|
+
// Race lost (409 not-paused) or transient — try the next task
|
|
562
621
|
}
|
|
563
622
|
}
|
|
564
623
|
return null;
|
|
565
624
|
} catch (err) {
|
|
625
|
+
if (err instanceof AgentPausedError) throw err;
|
|
626
|
+
if (isPausedResponse(err)) throw new AgentPausedError();
|
|
566
627
|
logActivity(agentDir, { type: 'error', error: `Task poll failed: ${err.message}` });
|
|
567
628
|
return null;
|
|
568
629
|
}
|