@possumtech/rummy 0.3.1 → 0.4.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/.env.example +11 -0
- package/README.md +5 -1
- package/SPEC.md +31 -17
- package/migrations/001_initial_schema.sql +2 -3
- package/package.json +1 -1
- package/src/agent/AgentLoop.js +50 -151
- package/src/agent/KnownStore.js +15 -7
- package/src/agent/TurnExecutor.js +75 -318
- package/src/agent/XmlParser.js +25 -4
- package/src/agent/known_queries.sql +1 -1
- package/src/agent/known_store.sql +11 -61
- package/src/agent/runs.sql +2 -2
- package/src/hooks/Hooks.js +1 -0
- package/src/hooks/ToolRegistry.js +6 -5
- package/src/plugins/ask_user/ask_userDoc.js +3 -8
- package/src/plugins/budget/README.md +26 -18
- package/src/plugins/budget/budget.js +60 -3
- package/src/plugins/budget/recovery.js +47 -0
- package/src/plugins/cp/cpDoc.js +4 -9
- package/src/plugins/env/envDoc.js +3 -8
- package/src/plugins/get/get.js +2 -4
- package/src/plugins/get/getDoc.js +11 -18
- package/src/plugins/helpers.js +2 -2
- package/src/plugins/instructions/instructions.js +3 -2
- package/src/plugins/instructions/preamble.md +27 -16
- package/src/plugins/known/known.js +63 -8
- package/src/plugins/known/knownDoc.js +10 -14
- package/src/plugins/mv/mvDoc.js +6 -21
- package/src/plugins/policy/policy.js +47 -0
- package/src/plugins/progress/progress.js +9 -45
- package/src/plugins/prompt/prompt.js +10 -1
- package/src/plugins/rm/rmDoc.js +5 -10
- package/src/plugins/rpc/rpc.js +3 -1
- package/src/plugins/set/set.js +82 -85
- package/src/plugins/set/setDoc.js +28 -41
- package/src/plugins/sh/shDoc.js +2 -7
- package/src/plugins/summarize/summarize.js +7 -0
- package/src/plugins/summarize/summarizeDoc.js +6 -11
- package/src/plugins/think/think.js +12 -0
- package/src/plugins/think/thinkDoc.js +18 -0
- package/src/plugins/unknown/unknown.js +21 -0
- package/src/plugins/unknown/unknownDoc.js +9 -14
- package/src/plugins/update/update.js +7 -0
- package/src/plugins/update/updateDoc.js +6 -11
- package/src/server/ClientConnection.js +11 -1
- package/src/sql/v_model_context.sql +4 -4
|
@@ -6,6 +6,10 @@ import ResponseHealer from "./ResponseHealer.js";
|
|
|
6
6
|
import { countTokens } from "./tokens.js";
|
|
7
7
|
import XmlParser from "./XmlParser.js";
|
|
8
8
|
|
|
9
|
+
const ACTION_SCHEMES = new Set(["get", "set", "rm", "mv", "cp", "sh", "env", "search"]);
|
|
10
|
+
const MUTATION_SCHEMES = new Set(["set", "rm", "sh", "mv", "cp"]);
|
|
11
|
+
const READ_SCHEMES = new Set(["get", "env", "search"]);
|
|
12
|
+
|
|
9
13
|
export default class TurnExecutor {
|
|
10
14
|
#db;
|
|
11
15
|
#llmProvider;
|
|
@@ -117,13 +121,6 @@ export default class TurnExecutor {
|
|
|
117
121
|
sequence: turn,
|
|
118
122
|
});
|
|
119
123
|
|
|
120
|
-
const unresolved = await this.#knownStore.getUnresolved(currentRunId);
|
|
121
|
-
if (unresolved.length > 0) {
|
|
122
|
-
throw new Error(
|
|
123
|
-
msg("error.unresolved_proposed", { count: unresolved.length }),
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
124
|
// Build RummyContext before turn.started so plugins can write entries
|
|
128
125
|
const rummy = new RummyContext(
|
|
129
126
|
{
|
|
@@ -297,6 +294,10 @@ export default class TurnExecutor {
|
|
|
297
294
|
/\b(503|429|timeout|ECONNREFUSED|ECONNRESET|unavailable)\b/i.test(
|
|
298
295
|
e.message,
|
|
299
296
|
);
|
|
297
|
+
const isContextExceeded = (e) =>
|
|
298
|
+
/\b(context.*(size|length|limit)|token.*(limit|exceed)|too.*(long|large))\b/i.test(
|
|
299
|
+
e.message,
|
|
300
|
+
);
|
|
300
301
|
|
|
301
302
|
for (let llmAttempt = 0; ; llmAttempt++) {
|
|
302
303
|
try {
|
|
@@ -315,6 +316,18 @@ export default class TurnExecutor {
|
|
|
315
316
|
await new Promise((r) => setTimeout(r, delay));
|
|
316
317
|
continue;
|
|
317
318
|
}
|
|
319
|
+
if (isContextExceeded(err)) {
|
|
320
|
+
console.warn(
|
|
321
|
+
`[RUMMY] LLM context exceeded: ${err.message.slice(0, 120)}. Returning 413.`,
|
|
322
|
+
);
|
|
323
|
+
return {
|
|
324
|
+
turn,
|
|
325
|
+
turnId: turnRow.id,
|
|
326
|
+
status: 413,
|
|
327
|
+
assembledTokens,
|
|
328
|
+
contextSize,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
318
331
|
throw err;
|
|
319
332
|
}
|
|
320
333
|
}
|
|
@@ -371,15 +384,7 @@ export default class TurnExecutor {
|
|
|
371
384
|
});
|
|
372
385
|
|
|
373
386
|
// --- PHASE 1: RECORD ---
|
|
374
|
-
// Split lifecycle signals from action commands.
|
|
375
|
-
// Lifecycle signals (summarize, update, unknown, known) are state
|
|
376
|
-
// declarations — always recorded, never 409'd by sequential dispatch.
|
|
377
|
-
const LIFECYCLE = new Set(["summarize", "update", "unknown", "known"]);
|
|
378
|
-
|
|
379
387
|
const recorded = [];
|
|
380
|
-
const lifecycle = [];
|
|
381
|
-
const actions = [];
|
|
382
|
-
|
|
383
388
|
for (const cmd of commands) {
|
|
384
389
|
const entry = await this.#record(
|
|
385
390
|
currentRunId,
|
|
@@ -388,33 +393,19 @@ export default class TurnExecutor {
|
|
|
388
393
|
mode,
|
|
389
394
|
cmd,
|
|
390
395
|
);
|
|
391
|
-
if (
|
|
392
|
-
recorded.push(entry);
|
|
393
|
-
|
|
394
|
-
if (LIFECYCLE.has(entry.scheme)) {
|
|
395
|
-
lifecycle.push(entry);
|
|
396
|
-
} else {
|
|
397
|
-
actions.push(entry);
|
|
398
|
-
}
|
|
396
|
+
if (entry) recorded.push(entry);
|
|
399
397
|
}
|
|
400
398
|
|
|
401
399
|
// --- PHASE 2: DISPATCH ---
|
|
400
|
+
// Sequential queue. Each tool completes before the next starts.
|
|
401
|
+
// On failure: abort remaining. On proposal: notify client, await
|
|
402
|
+
// resolution, continue.
|
|
402
403
|
let hasErrors = false;
|
|
403
|
-
let hasProposed = false;
|
|
404
404
|
let abortAfter = null;
|
|
405
|
-
const dispatched = [...lifecycle];
|
|
406
|
-
|
|
407
|
-
// Lifecycle signals first — always dispatched, never aborted.
|
|
408
|
-
for (const entry of lifecycle) {
|
|
409
|
-
await this.#hooks.tool.before.emit({ entry, rummy });
|
|
410
|
-
await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
|
|
411
|
-
await this.#hooks.tool.after.emit({ entry, rummy });
|
|
412
|
-
await this.#hooks.entry.created.emit(entry);
|
|
413
|
-
}
|
|
414
405
|
|
|
415
|
-
for (const entry of
|
|
406
|
+
for (const entry of recorded) {
|
|
416
407
|
if (abortAfter) {
|
|
417
|
-
const errorMsg = `Aborted — preceding <${abortAfter}>
|
|
408
|
+
const errorMsg = `Aborted — preceding <${abortAfter}> failed.`;
|
|
418
409
|
await this.#knownStore.upsert(
|
|
419
410
|
currentRunId,
|
|
420
411
|
turn,
|
|
@@ -431,35 +422,40 @@ export default class TurnExecutor {
|
|
|
431
422
|
await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
|
|
432
423
|
await this.#hooks.tool.after.emit({ entry, rummy });
|
|
433
424
|
await this.#hooks.entry.created.emit(entry);
|
|
434
|
-
dispatched.push(entry);
|
|
435
425
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
path: entry.resultPath || entry.path,
|
|
439
|
-
});
|
|
440
|
-
if (row?.status === 202) {
|
|
441
|
-
hasProposed = true;
|
|
442
|
-
abortAfter = entry.scheme;
|
|
443
|
-
} else if (row?.status >= 400) {
|
|
444
|
-
hasErrors = true;
|
|
445
|
-
abortAfter = entry.scheme;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
426
|
+
// Materialize proposals for this entry (set revisions → 202)
|
|
427
|
+
await this.#hooks.turn.proposing.emit({ rummy, recorded: [entry] });
|
|
448
428
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
429
|
+
// Check for any proposals created by this entry's dispatch
|
|
430
|
+
const proposed = await this.#knownStore.getUnresolved(currentRunId);
|
|
431
|
+
for (const p of proposed) {
|
|
432
|
+
await this.#hooks.turn.proposal.emit({
|
|
433
|
+
projectId,
|
|
434
|
+
run: currentAlias,
|
|
435
|
+
proposed: [p],
|
|
436
|
+
});
|
|
437
|
+
await this.#knownStore.waitForResolution(currentRunId, p.path);
|
|
438
|
+
const resolved = await this.#db.get_entry_state.get({
|
|
439
|
+
run_id: currentRunId,
|
|
440
|
+
path: p.path,
|
|
441
|
+
});
|
|
442
|
+
if (resolved?.status >= 400) {
|
|
443
|
+
hasErrors = true;
|
|
444
|
+
abortAfter = entry.scheme;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
453
447
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
448
|
+
// Also check the entry itself for direct failures
|
|
449
|
+
if (!hasErrors) {
|
|
450
|
+
const entryPath = entry.resultPath || entry.path;
|
|
457
451
|
const row = await this.#db.get_entry_state.get({
|
|
458
452
|
run_id: currentRunId,
|
|
459
|
-
path:
|
|
453
|
+
path: entryPath,
|
|
460
454
|
});
|
|
461
|
-
if (row?.status
|
|
462
|
-
|
|
455
|
+
if (row?.status >= 400) {
|
|
456
|
+
hasErrors = true;
|
|
457
|
+
abortAfter = entry.scheme;
|
|
458
|
+
}
|
|
463
459
|
}
|
|
464
460
|
}
|
|
465
461
|
|
|
@@ -468,8 +464,7 @@ export default class TurnExecutor {
|
|
|
468
464
|
// budget recovery phase before continuing.
|
|
469
465
|
let budgetRecovery = null;
|
|
470
466
|
// Use actual prompt_tokens from this turn's LLM response as the ground-truth
|
|
471
|
-
//
|
|
472
|
-
const currentPromptTokens = result.usage?.prompt_tokens ?? 0;
|
|
467
|
+
// Post-dispatch budget check — demotion handled by budget plugin
|
|
473
468
|
if (contextSize) {
|
|
474
469
|
const postMat = await this.#materializeTurnContext({
|
|
475
470
|
runId: currentRunId,
|
|
@@ -481,92 +476,31 @@ export default class TurnExecutor {
|
|
|
481
476
|
contextSize,
|
|
482
477
|
demoted,
|
|
483
478
|
});
|
|
484
|
-
|
|
479
|
+
budgetRecovery = await this.#hooks.budget.postDispatch({
|
|
485
480
|
contextSize,
|
|
486
481
|
messages: postMat.messages,
|
|
487
482
|
rows: postMat.rows,
|
|
488
|
-
|
|
483
|
+
runId: currentRunId,
|
|
484
|
+
loopId: currentLoopId,
|
|
485
|
+
turn,
|
|
486
|
+
db: this.#db,
|
|
487
|
+
store: this.#knownStore,
|
|
489
488
|
});
|
|
490
|
-
if (postBudget.status === 413) {
|
|
491
|
-
// Demote this turn's data entries.
|
|
492
|
-
const demotedEntries = await this.#db.demote_turn_data_entries.all({
|
|
493
|
-
run_id: currentRunId,
|
|
494
|
-
turn,
|
|
495
|
-
});
|
|
496
|
-
const paths = demotedEntries.map((r) => r.path).join(", ");
|
|
497
|
-
|
|
498
|
-
// Also summarize the prompt — forces the model to earn it back.
|
|
499
|
-
const promptRow = postMat.rows.find((r) => r.scheme === "prompt");
|
|
500
|
-
if (promptRow) {
|
|
501
|
-
await this.#knownStore.setFidelity(
|
|
502
|
-
currentRunId,
|
|
503
|
-
promptRow.path,
|
|
504
|
-
"summary",
|
|
505
|
-
);
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// Re-materialize after both demotions for accurate token count.
|
|
509
|
-
const recoveryMat = await this.#materializeTurnContext({
|
|
510
|
-
runId: currentRunId,
|
|
511
|
-
loopId: currentLoopId,
|
|
512
|
-
turn,
|
|
513
|
-
systemPrompt,
|
|
514
|
-
mode,
|
|
515
|
-
toolSet: effectiveToolSet,
|
|
516
|
-
contextSize,
|
|
517
|
-
demoted,
|
|
518
|
-
});
|
|
519
|
-
const recoveryBudget = await this.#hooks.budget.enforce({
|
|
520
|
-
contextSize,
|
|
521
|
-
messages: recoveryMat.messages,
|
|
522
|
-
rows: recoveryMat.rows,
|
|
523
|
-
lastPromptTokens: currentPromptTokens,
|
|
524
|
-
});
|
|
525
|
-
const safeLevel = Math.floor(contextSize * 0.9);
|
|
526
|
-
const tokensToFree = Math.max(
|
|
527
|
-
0,
|
|
528
|
-
recoveryBudget.assembledTokens - safeLevel,
|
|
529
|
-
);
|
|
530
|
-
|
|
531
|
-
const promptLine =
|
|
532
|
-
tokensToFree > 0
|
|
533
|
-
? `Info: Prompt auto-summarized. Full prompt restores automatically when you free ${tokensToFree} tokens.`
|
|
534
|
-
: "Info: Prompt auto-summarized. It will restore automatically.";
|
|
535
|
-
const body = [
|
|
536
|
-
"Error 413: Context Size Exceeded",
|
|
537
|
-
"",
|
|
538
|
-
"Required: YOU MUST demote larger and/or less relevant items to optimize your context.",
|
|
539
|
-
`Info: ${paths} have been automatically summarized to avoid overflow.`,
|
|
540
|
-
promptLine,
|
|
541
|
-
"Info: YOU MAY use bulk patterns to demote and promote entries by pattern.",
|
|
542
|
-
"Info: Well-designed paths and summaries improve context management.",
|
|
543
|
-
'Example: <set path="known://people/*" fidelity="summary"/>',
|
|
544
|
-
].join("\n");
|
|
545
|
-
|
|
546
|
-
await this.#knownStore.upsert(
|
|
547
|
-
currentRunId,
|
|
548
|
-
turn,
|
|
549
|
-
`budget://${currentLoopId}/${turn}`,
|
|
550
|
-
body,
|
|
551
|
-
413,
|
|
552
|
-
{ loopId: currentLoopId },
|
|
553
|
-
);
|
|
554
|
-
|
|
555
|
-
budgetRecovery = {
|
|
556
|
-
target: safeLevel,
|
|
557
|
-
promptPath: promptRow?.path ?? null,
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
489
|
}
|
|
561
490
|
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
const updateEntry = lifecycle.find((e) => e.scheme === "update");
|
|
491
|
+
const summaryEntry = recorded.findLast((e) => e.scheme === "summarize");
|
|
492
|
+
const updateEntry = recorded.findLast((e) => e.scheme === "update");
|
|
565
493
|
let summaryText = summaryEntry?.body || null;
|
|
566
494
|
let updateText = updateEntry?.body || null;
|
|
567
495
|
|
|
568
|
-
// If model sent both,
|
|
569
|
-
if (summaryText && updateText)
|
|
496
|
+
// If model sent both, last signal wins — respects the model's final intent
|
|
497
|
+
if (summaryText && updateText) {
|
|
498
|
+
const lastLifecycle = recorded.findLast(
|
|
499
|
+
(e) => e.scheme === "summarize" || e.scheme === "update",
|
|
500
|
+
);
|
|
501
|
+
if (lastLifecycle.scheme === "summarize") updateText = null;
|
|
502
|
+
else summaryText = null;
|
|
503
|
+
}
|
|
570
504
|
|
|
571
505
|
// If model says "done" but actions failed, override — the model's
|
|
572
506
|
// assertion that it's done is false if it failed to do what it tried.
|
|
@@ -598,11 +532,7 @@ export default class TurnExecutor {
|
|
|
598
532
|
|
|
599
533
|
// --- Classify for return value ---
|
|
600
534
|
|
|
601
|
-
const actionCalls = recorded.filter((e) =>
|
|
602
|
-
["get", "set", "rm", "mv", "cp", "sh", "env", "search"].includes(
|
|
603
|
-
e.scheme,
|
|
604
|
-
),
|
|
605
|
-
);
|
|
535
|
+
const actionCalls = recorded.filter((e) => ACTION_SCHEMES.has(e.scheme));
|
|
606
536
|
const writeCalls = recorded.filter(
|
|
607
537
|
(e) =>
|
|
608
538
|
e.scheme === "known" ||
|
|
@@ -610,12 +540,8 @@ export default class TurnExecutor {
|
|
|
610
540
|
);
|
|
611
541
|
const unknownCalls = recorded.filter((e) => e.scheme === "unknown");
|
|
612
542
|
|
|
613
|
-
const hasAct = actionCalls.some((c) =>
|
|
614
|
-
|
|
615
|
-
);
|
|
616
|
-
const hasReads = actionCalls.some((c) =>
|
|
617
|
-
["get", "env", "search"].includes(c.scheme),
|
|
618
|
-
);
|
|
543
|
+
const hasAct = actionCalls.some((c) => MUTATION_SCHEMES.has(c.scheme));
|
|
544
|
+
const hasReads = actionCalls.some((c) => READ_SCHEMES.has(c.scheme));
|
|
619
545
|
const hasWrites = writeCalls.length > 0 || unknownCalls.length > 0;
|
|
620
546
|
const flags = { hasAct, hasReads, hasWrites };
|
|
621
547
|
|
|
@@ -651,83 +577,7 @@ export default class TurnExecutor {
|
|
|
651
577
|
* Returns the recorded entry descriptor, or null if rejected/skipped.
|
|
652
578
|
*/
|
|
653
579
|
async #record(runId, loopId, turn, mode, cmd) {
|
|
654
|
-
if (mode === "ask") {
|
|
655
|
-
if (cmd.name === "sh") {
|
|
656
|
-
console.warn("[RUMMY] Rejected <sh> in ask mode");
|
|
657
|
-
return null;
|
|
658
|
-
}
|
|
659
|
-
if (cmd.name === "set" && cmd.path && cmd.body) {
|
|
660
|
-
const scheme = KnownStore.scheme(cmd.path);
|
|
661
|
-
if (scheme === null) {
|
|
662
|
-
console.warn(
|
|
663
|
-
`[RUMMY] Rejected file edit to ${cmd.path} in ${mode} mode`,
|
|
664
|
-
);
|
|
665
|
-
return null;
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
if (cmd.name === "rm" && cmd.path) {
|
|
669
|
-
const scheme = KnownStore.scheme(cmd.path);
|
|
670
|
-
if (scheme === null) {
|
|
671
|
-
console.warn(`[RUMMY] Rejected file rm of ${cmd.path} in ask mode`);
|
|
672
|
-
return null;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
if ((cmd.name === "mv" || cmd.name === "cp") && cmd.to) {
|
|
676
|
-
const destScheme = KnownStore.scheme(cmd.to);
|
|
677
|
-
if (destScheme === null) {
|
|
678
|
-
console.warn(
|
|
679
|
-
`[RUMMY] Rejected ${cmd.name} to file ${cmd.to} in ask mode`,
|
|
680
|
-
);
|
|
681
|
-
return null;
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
580
|
const scheme = cmd.name;
|
|
687
|
-
|
|
688
|
-
// Structural tags — recorded like any other entry
|
|
689
|
-
if (scheme === "summarize" || scheme === "update") {
|
|
690
|
-
const statusPath = await this.#knownStore.slugPath(
|
|
691
|
-
runId,
|
|
692
|
-
scheme,
|
|
693
|
-
cmd.body,
|
|
694
|
-
);
|
|
695
|
-
await this.#knownStore.upsert(runId, turn, statusPath, cmd.body, 200, {
|
|
696
|
-
loopId,
|
|
697
|
-
});
|
|
698
|
-
return {
|
|
699
|
-
scheme,
|
|
700
|
-
body: cmd.body,
|
|
701
|
-
path: statusPath,
|
|
702
|
-
resultPath: statusPath,
|
|
703
|
-
attributes: null,
|
|
704
|
-
};
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// Unknown — deduplicated, sticky
|
|
708
|
-
if (scheme === "unknown") {
|
|
709
|
-
const existingValues = await this.#knownStore.getUnknownValues(runId);
|
|
710
|
-
if (existingValues.has(cmd.body)) {
|
|
711
|
-
console.warn(`[RUMMY] Unknown deduped: "${cmd.body.slice(0, 60)}"`);
|
|
712
|
-
return null;
|
|
713
|
-
}
|
|
714
|
-
const unknownPath = await this.#knownStore.slugPath(
|
|
715
|
-
runId,
|
|
716
|
-
"unknown",
|
|
717
|
-
cmd.body,
|
|
718
|
-
);
|
|
719
|
-
await this.#knownStore.upsert(runId, turn, unknownPath, cmd.body, 200, {
|
|
720
|
-
loopId,
|
|
721
|
-
});
|
|
722
|
-
return {
|
|
723
|
-
scheme,
|
|
724
|
-
path: unknownPath,
|
|
725
|
-
body: cmd.body,
|
|
726
|
-
resultPath: unknownPath,
|
|
727
|
-
attributes: null,
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
|
|
731
581
|
const rawTarget = cmd.path || cmd.command || cmd.question || "";
|
|
732
582
|
// Reject paths that are likely reasoning bleed — too long or contain non-printing chars
|
|
733
583
|
if (rawTarget.length > 512 || /\p{Cc}/u.test(rawTarget)) {
|
|
@@ -766,108 +616,15 @@ export default class TurnExecutor {
|
|
|
766
616
|
const { name: _, ...attributes } = cmd;
|
|
767
617
|
if (cmd.path) attributes.path = target;
|
|
768
618
|
|
|
769
|
-
// known tool or naked write → known:// slug from body
|
|
770
|
-
if (scheme === "known" || (scheme === "set" && !cmd.path)) {
|
|
771
|
-
if (!cmd.body) return null;
|
|
772
|
-
|
|
773
|
-
// Size gate: reject entries > 512 tokens — force atomic entries
|
|
774
|
-
const entryTokens = countTokens(cmd.body);
|
|
775
|
-
const MAX_ENTRY_TOKENS = 512;
|
|
776
|
-
if (scheme === "known" && entryTokens > MAX_ENTRY_TOKENS) {
|
|
777
|
-
const rejectPath = await this.#knownStore.slugPath(
|
|
778
|
-
runId,
|
|
779
|
-
scheme,
|
|
780
|
-
cmd.body,
|
|
781
|
-
);
|
|
782
|
-
await this.#knownStore.upsert(
|
|
783
|
-
runId,
|
|
784
|
-
turn,
|
|
785
|
-
rejectPath,
|
|
786
|
-
`Entry too large (${entryTokens} tokens, max ${MAX_ENTRY_TOKENS}). Sort the information, ideas, or plans carefully into multiple entries.`,
|
|
787
|
-
413,
|
|
788
|
-
{ loopId },
|
|
789
|
-
);
|
|
790
|
-
return {
|
|
791
|
-
scheme,
|
|
792
|
-
path: rejectPath,
|
|
793
|
-
body: "",
|
|
794
|
-
resultPath: rejectPath,
|
|
795
|
-
attributes,
|
|
796
|
-
status: 413,
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
let knownPath = cmd.path;
|
|
801
|
-
if (!knownPath) {
|
|
802
|
-
knownPath = await this.#knownStore.slugPath(
|
|
803
|
-
runId,
|
|
804
|
-
"known",
|
|
805
|
-
cmd.body,
|
|
806
|
-
cmd.summary,
|
|
807
|
-
);
|
|
808
|
-
}
|
|
809
|
-
// Dedup: if this exact path already exists, update rather than duplicate
|
|
810
|
-
const existing = await this.#knownStore.getEntriesByPattern(
|
|
811
|
-
runId,
|
|
812
|
-
knownPath,
|
|
813
|
-
null,
|
|
814
|
-
);
|
|
815
|
-
if (existing.length > 0) {
|
|
816
|
-
// Path exists — update body and turn, skip creating a new entry
|
|
817
|
-
await this.#knownStore.upsert(
|
|
818
|
-
runId,
|
|
819
|
-
turn,
|
|
820
|
-
existing[0].path,
|
|
821
|
-
cmd.body || existing[0].body,
|
|
822
|
-
200,
|
|
823
|
-
{
|
|
824
|
-
attributes,
|
|
825
|
-
loopId,
|
|
826
|
-
},
|
|
827
|
-
);
|
|
828
|
-
return {
|
|
829
|
-
scheme: "known",
|
|
830
|
-
path: existing[0].path,
|
|
831
|
-
body: cmd.body || existing[0].body,
|
|
832
|
-
resultPath: existing[0].path,
|
|
833
|
-
attributes,
|
|
834
|
-
};
|
|
835
|
-
}
|
|
836
|
-
await this.#knownStore.upsert(runId, turn, knownPath, cmd.body, 200, {
|
|
837
|
-
attributes,
|
|
838
|
-
loopId,
|
|
839
|
-
});
|
|
840
|
-
return {
|
|
841
|
-
scheme: "known",
|
|
842
|
-
path: knownPath,
|
|
843
|
-
body: cmd.body,
|
|
844
|
-
resultPath: knownPath,
|
|
845
|
-
attributes,
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
|
|
849
619
|
const body = cmd.body || cmd.command || cmd.question || "";
|
|
850
620
|
|
|
851
621
|
// Filter: plugins can validate/transform before recording
|
|
852
622
|
const filtered = await this.#hooks.entry.recording.filter(
|
|
853
623
|
{ scheme, path: resultPath, body, attributes, status: 200 },
|
|
854
|
-
{ runId, turn, loopId },
|
|
624
|
+
{ runId, turn, loopId, mode },
|
|
855
625
|
);
|
|
856
626
|
if (filtered.status >= 400) return filtered;
|
|
857
627
|
|
|
858
|
-
// Record the entry — 200 OK, handlers change status during dispatch
|
|
859
|
-
await this.#knownStore.upsert(
|
|
860
|
-
runId,
|
|
861
|
-
turn,
|
|
862
|
-
filtered.path,
|
|
863
|
-
filtered.body,
|
|
864
|
-
200,
|
|
865
|
-
{
|
|
866
|
-
attributes: filtered.attributes,
|
|
867
|
-
loopId,
|
|
868
|
-
},
|
|
869
|
-
);
|
|
870
|
-
|
|
871
628
|
return {
|
|
872
629
|
scheme: filtered.scheme,
|
|
873
630
|
path: filtered.path,
|
package/src/agent/XmlParser.js
CHANGED
|
@@ -14,8 +14,6 @@ export const ALL_TOOLS = new Set([
|
|
|
14
14
|
"update",
|
|
15
15
|
"unknown",
|
|
16
16
|
"think",
|
|
17
|
-
"thought",
|
|
18
|
-
"mcp",
|
|
19
17
|
]);
|
|
20
18
|
|
|
21
19
|
/**
|
|
@@ -145,6 +143,8 @@ export default class XmlParser {
|
|
|
145
143
|
* @param {string} content - Raw model response text
|
|
146
144
|
* @returns {{ commands: Array, warnings: string[], unparsed: string }}
|
|
147
145
|
*/
|
|
146
|
+
static MAX_COMMANDS = Number(process.env.RUMMY_MAX_COMMANDS) || 99;
|
|
147
|
+
|
|
148
148
|
static parse(content) {
|
|
149
149
|
if (!content) return { commands: [], warnings: [], unparsed: "" };
|
|
150
150
|
|
|
@@ -156,13 +156,20 @@ export default class XmlParser {
|
|
|
156
156
|
const textChunks = [];
|
|
157
157
|
let current = null;
|
|
158
158
|
let ended = false;
|
|
159
|
+
let capped = false;
|
|
159
160
|
|
|
160
161
|
const parser = new Parser(
|
|
161
162
|
{
|
|
162
163
|
onopentag(name, attrs) {
|
|
164
|
+
if (capped) return;
|
|
163
165
|
if (!ALL_TOOLS.has(name)) {
|
|
164
166
|
if (current) {
|
|
165
|
-
|
|
167
|
+
const attrStr = Object.entries(attrs)
|
|
168
|
+
.map(([k, v]) => v === "" ? k : `${k}="${v}"`)
|
|
169
|
+
.join(" ");
|
|
170
|
+
current.rawBody += attrStr
|
|
171
|
+
? `<${name} ${attrStr}>`
|
|
172
|
+
: `<${name}>`;
|
|
166
173
|
}
|
|
167
174
|
return;
|
|
168
175
|
}
|
|
@@ -177,10 +184,17 @@ export default class XmlParser {
|
|
|
177
184
|
);
|
|
178
185
|
}
|
|
179
186
|
|
|
187
|
+
if (commands.length >= XmlParser.MAX_COMMANDS) {
|
|
188
|
+
capped = true;
|
|
189
|
+
current = null;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
180
193
|
current = { name, attrs, rawBody: "" };
|
|
181
194
|
},
|
|
182
195
|
|
|
183
196
|
ontext(text) {
|
|
197
|
+
if (capped) return;
|
|
184
198
|
if (current) {
|
|
185
199
|
current.rawBody += text;
|
|
186
200
|
} else {
|
|
@@ -189,6 +203,7 @@ export default class XmlParser {
|
|
|
189
203
|
},
|
|
190
204
|
|
|
191
205
|
onclosetag(name, isImplied) {
|
|
206
|
+
if (capped) return;
|
|
192
207
|
if (current && name === current.name) {
|
|
193
208
|
if (ended) {
|
|
194
209
|
warnings.push(`Unclosed <${name}> tag — content captured anyway`);
|
|
@@ -230,7 +245,7 @@ export default class XmlParser {
|
|
|
230
245
|
parser.end();
|
|
231
246
|
|
|
232
247
|
// Flush any unclosed tool tag
|
|
233
|
-
if (current) {
|
|
248
|
+
if (current && !capped) {
|
|
234
249
|
warnings.push(`Unclosed <${current.name}> tag — content captured anyway`);
|
|
235
250
|
commands.push(
|
|
236
251
|
resolveCommand(current.name, current.attrs, current.rawBody),
|
|
@@ -238,6 +253,12 @@ export default class XmlParser {
|
|
|
238
253
|
current = null;
|
|
239
254
|
}
|
|
240
255
|
|
|
256
|
+
if (capped) {
|
|
257
|
+
warnings.push(
|
|
258
|
+
`Tool call limit (${XmlParser.MAX_COMMANDS}) reached — remaining commands dropped`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
241
262
|
const unparsed = textChunks.join("").trim();
|
|
242
263
|
return { commands, warnings, unparsed };
|
|
243
264
|
}
|