@causyne/agent 1.10.12 → 1.10.13
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/LICENSE +1 -1
- package/docs/ARCHITECTURE.md +46 -1
- package/package.json +2 -1
- package/src/agentLoop.js +136 -90
- package/src/benchmarks/planCache.js +159 -0
- package/src/commands.js +83 -0
- package/src/cortex/autoCondenser.js +1 -1
- package/src/hooks.js +136 -0
- package/src/planCache.js +295 -14
- package/src/plugins/pluginManager.js +425 -0
- package/src/plugins/pluginRunner.js +50 -0
- package/src/sandbox.js +21 -0
- package/src/taskShape.js +1 -1
- package/src/toolPacks.js +6 -3
- package/src/tools/deferredMcpSearch.js +2 -2
- package/src/tools/pluginManager.js +61 -0
- package/src/tools/registry.js +47 -53
- package/src/tools/toolForge.js +47 -95
package/LICENSE
CHANGED
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -36,7 +36,7 @@ are application policies and require adversarial review.
|
|
|
36
36
|
| Models | `providers/`, `modelCatalogue.js` |
|
|
37
37
|
| Context and memory | `memory.js`, `memoryManager.js`, `contextManager.js`, `cortex/` |
|
|
38
38
|
| Recovery | `checkpoints.js`, `gitSnapshot.js`, `sessionStore.js` |
|
|
39
|
-
| Extensions | `
|
|
39
|
+
| Extensions | `mcp/`, `plugins/`, `hooks.js`, `skillsManager.js`, `toolPacks.js` |
|
|
40
40
|
| Readable interpreter source | `src/tool-scripts/` |
|
|
41
41
|
|
|
42
42
|
The current implementation remains JavaScript/ESM on Node.js 24+. The accepted
|
|
@@ -44,6 +44,51 @@ language direction is incremental strict TypeScript. Runtime validation is
|
|
|
44
44
|
still needed at model, filesystem, subprocess and extension boundaries.
|
|
45
45
|
Rust is a possible narrow native worker later; no Rust rewrite is underway.
|
|
46
46
|
|
|
47
|
+
## Tool and extension boundaries
|
|
48
|
+
|
|
49
|
+
Tool schemas are assembled once from native tools, explicitly enabled plugins,
|
|
50
|
+
and granted MCP servers. Denied tools are removed before the model sees them,
|
|
51
|
+
native names win collisions, and large capability groups remain deferred until
|
|
52
|
+
the task shape or `tool_search` makes them relevant.
|
|
53
|
+
|
|
54
|
+
Local plugins use a static `plugin.json` manifest. Creating a plugin leaves it
|
|
55
|
+
disabled. Enabling it pins a digest of every file the plugin process may read;
|
|
56
|
+
any later manifest, source, helper or asset change revokes trust. Discovery
|
|
57
|
+
validates static manifests and hashes bytes without importing JavaScript, so code cannot run
|
|
58
|
+
during pre-trust initialization. A plugin tool runs in a separate Node process
|
|
59
|
+
with the permission model enabled and only its declared workspace, network, or
|
|
60
|
+
subprocess capabilities. Plugin calls still pass through Causyne's sandbox and
|
|
61
|
+
approval gates.
|
|
62
|
+
|
|
63
|
+
Hooks are user-owned argv arrays stored in the local Causyne configuration.
|
|
64
|
+
They never pass through a shell and project repositories are not searched for
|
|
65
|
+
startup hooks. `PreToolUse` runs after the built-in sandbox and before an
|
|
66
|
+
approval prompt; exit code 2 denies, and any other failure fails closed.
|
|
67
|
+
`PostToolUse` can annotate a result or reject it without rewriting history.
|
|
68
|
+
|
|
69
|
+
Host execution remains the default because Causyne must work in the directory
|
|
70
|
+
the user selected. Optional container isolation can be added as an execution
|
|
71
|
+
profile later, but it must remain explicit: mounting a workspace into Docker is
|
|
72
|
+
a different trust boundary and cannot silently replace native desktop access.
|
|
73
|
+
|
|
74
|
+
## Plan cache and context pressure
|
|
75
|
+
|
|
76
|
+
Completed multi-step runs teach argument-free plan templates. Lookup tries an
|
|
77
|
+
indexed exact signature first, then bounded lexical similarity only for a task
|
|
78
|
+
shape with enough confidence. Latency, hit type and recent hit rate are recorded;
|
|
79
|
+
lookup temporarily cools down when a populated cache repeatedly misses while
|
|
80
|
+
new successful plans may continue warming it. `/plans import` accepts sanitized
|
|
81
|
+
offline samples for known workloads, and `/plans` exposes the measurements.
|
|
82
|
+
`npm run benchmark:plan-cache` measures exact lookup, fuzzy lookup, misses and
|
|
83
|
+
prewarming at bounded cache sizes in an isolated temporary state directory. It
|
|
84
|
+
is a synthetic cache microbenchmark, not evidence for model accuracy, token
|
|
85
|
+
cost, or end-to-end latency claims from the research paper.
|
|
86
|
+
|
|
87
|
+
Context uses per-tool result budgets, oversized-result snipping, decay, a bounded
|
|
88
|
+
sliding window, and structural summaries. Full tool output is spilled before it
|
|
89
|
+
is shortened. Material compaction is recorded in the audit log, and summary
|
|
90
|
+
collapse emits a quiet terminal notice so context loss is visible.
|
|
91
|
+
|
|
47
92
|
## Removed boundaries
|
|
48
93
|
|
|
49
94
|
No active pairing, relay, WebRTC/P2P, mobile command surface, background daemon
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@causyne/agent",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.13",
|
|
4
4
|
"description": "Agentic AI for software, tools, and computer workflows",
|
|
5
5
|
"main": "bin/causyne.js",
|
|
6
6
|
"type": "module",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"start": "node ./bin/causyne.js",
|
|
25
25
|
"test": "node --test --import ./tests/helpers/isolateState.js \"tests/**/*.test.js\"",
|
|
26
26
|
"build": "node scripts/compile_skills.js",
|
|
27
|
+
"benchmark:plan-cache": "node src/benchmarks/planCache.js",
|
|
27
28
|
"check:release": "node scripts/check_release.js",
|
|
28
29
|
"check:public": "npm run check:release && node scripts/check_public_release.js",
|
|
29
30
|
"prepublishOnly": "npm run check:public && npm test && npm run typecheck:strict",
|
package/src/agentLoop.js
CHANGED
|
@@ -7,6 +7,7 @@ import { TaskBuffer } from './taskBuffer.js';
|
|
|
7
7
|
import { TOOL_DEFINITIONS, getDynamicTools, resolveTool, canonicalToolName } from './tools/registry.js';
|
|
8
8
|
import { Sandbox } from './sandbox.js';
|
|
9
9
|
import { auditLog } from './auditLog.js';
|
|
10
|
+
import { hasMatchingHook, runConfiguredHooks } from './hooks.js';
|
|
10
11
|
import { loadConfig } from './config.js';
|
|
11
12
|
import { isDevEmulator } from './devMode.js';
|
|
12
13
|
import { TokenTracker } from './tokenTracker.js';
|
|
@@ -14,7 +15,7 @@ import { TokenTracker } from './tokenTracker.js';
|
|
|
14
15
|
import { getMemoryContextString } from './memory.js';
|
|
15
16
|
import { MemoryManager } from './memoryManager.js';
|
|
16
17
|
import { cortexDb } from './cortex/vectorDb.js';
|
|
17
|
-
import { condenseAndSave } from './cortex/autoCondenser.js';
|
|
18
|
+
import { buildStructuralSummary, condenseAndSave } from './cortex/autoCondenser.js';
|
|
18
19
|
import { analyzeTask, buildTaskBrief, packsFromShape } from './taskShape.js';
|
|
19
20
|
import { lookupPlan, recordPlan, recordMiss, renderPlanHint, toStep } from './planCache.js';
|
|
20
21
|
import { routedConfig } from './modelRouter.js';
|
|
@@ -497,18 +498,9 @@ export class AgentLoop {
|
|
|
497
498
|
// once per task rather than once per step.
|
|
498
499
|
let planHint = '';
|
|
499
500
|
try {
|
|
500
|
-
|
|
501
|
-
prompt: this.currentTaskPrompt || this.userPrompt,
|
|
502
|
-
archetype: this.taskShape ? this.taskShape.archetype : ''
|
|
503
|
-
});
|
|
504
|
-
if (match) {
|
|
505
|
-
planHint = renderPlanHint(match);
|
|
506
|
-
this._offeredPlanId = match.template.id;
|
|
507
|
-
this._planSimilarity = match.similarity;
|
|
508
|
-
this.debugLog(`plan cache hit ${(match.similarity * 100).toFixed(0)}% (${match.template.steps.length} steps)`);
|
|
509
|
-
}
|
|
501
|
+
planHint = renderPlanHint(this._planMatch);
|
|
510
502
|
} catch (e) {
|
|
511
|
-
this.debugLog(`plan cache
|
|
503
|
+
this.debugLog(`plan cache hint skipped: ${e.message}`);
|
|
512
504
|
}
|
|
513
505
|
|
|
514
506
|
return { lessonsLearned, designContext, antiRepeatDirective, universalSkillsContext, planHint };
|
|
@@ -601,6 +593,10 @@ export class AgentLoop {
|
|
|
601
593
|
for (const toolCall of toolCalls) {
|
|
602
594
|
const functionName = canonicalToolName(toolCall.function.name);
|
|
603
595
|
if (!AgentLoop.READ_ONLY_TOOLS.has(functionName)) continue;
|
|
596
|
+
// Prefetch begins before the serial dispatch path. If this tool has a
|
|
597
|
+
// PreToolUse hook, running it here would create a temporal pre-trust
|
|
598
|
+
// window where the read happens before the hook can deny it.
|
|
599
|
+
if (hasMatchingHook(this.config.hooks, 'PreToolUse', functionName)) continue;
|
|
604
600
|
|
|
605
601
|
let args;
|
|
606
602
|
try {
|
|
@@ -800,6 +796,7 @@ export class AgentLoop {
|
|
|
800
796
|
this._planSteps = [];
|
|
801
797
|
this._offeredPlanId = null;
|
|
802
798
|
this._planSimilarity = 0;
|
|
799
|
+
this._planMatch = null;
|
|
803
800
|
this._lessonSimilarity = 0;
|
|
804
801
|
this._lastToolResult = null;
|
|
805
802
|
this._paradigmShiftPending = false;
|
|
@@ -816,6 +813,36 @@ export class AgentLoop {
|
|
|
816
813
|
this.unlockPack(pack);
|
|
817
814
|
}
|
|
818
815
|
|
|
816
|
+
// Reuse a known route before the first model call. Exact keyword-set
|
|
817
|
+
// matches are always eligible; fuzzy reuse requires at least medium task
|
|
818
|
+
// classification confidence because APC's threshold study shows that
|
|
819
|
+
// aggressive fuzzy hits trade accuracy for speed.
|
|
820
|
+
try {
|
|
821
|
+
this._planMatch = lookupPlan({
|
|
822
|
+
prompt: this.userPrompt,
|
|
823
|
+
archetype: this.taskShape ? this.taskShape.archetype : '',
|
|
824
|
+
allowFuzzy: this.taskShape?.confidence !== 'low'
|
|
825
|
+
});
|
|
826
|
+
if (this._planMatch) {
|
|
827
|
+
this._offeredPlanId = this._planMatch.template.id;
|
|
828
|
+
this._planSimilarity = this._planMatch.similarity;
|
|
829
|
+
for (const step of this._planMatch.template.steps || []) {
|
|
830
|
+
if (!step.ok || !resolveTool(step.tool)) continue;
|
|
831
|
+
this.unlockedTools.add(step.tool);
|
|
832
|
+
const pack = packForTool(step.tool);
|
|
833
|
+
if (pack) this.unlockPack(pack);
|
|
834
|
+
}
|
|
835
|
+
this.debugLog(
|
|
836
|
+
`plan cache ${this._planMatch.matchType || 'fuzzy'} hit `
|
|
837
|
+
+ `${(this._planMatch.similarity * 100).toFixed(0)}% `
|
|
838
|
+
+ `(${this._planMatch.template.steps.length} steps, `
|
|
839
|
+
+ `${this._planMatch.telemetry?.lookupMs?.toFixed(2) || '0.00'} ms)`
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
} catch (e) {
|
|
843
|
+
this.debugLog(`plan cache lookup skipped: ${e.message}`);
|
|
844
|
+
}
|
|
845
|
+
|
|
819
846
|
this.debugLog(`task shape: ${this.taskShape.archetype} (${this.taskShape.confidence} confidence)`);
|
|
820
847
|
}
|
|
821
848
|
|
|
@@ -972,6 +999,13 @@ export class AgentLoop {
|
|
|
972
999
|
});
|
|
973
1000
|
if (compaction.totalReclaimed > 0) {
|
|
974
1001
|
this.debugLog(`progressive compaction reclaimed ${(compaction.totalReclaimed / 1024).toFixed(1)} KB [${compaction.stagesFired.join(', ')}]`);
|
|
1002
|
+
auditLog.log({
|
|
1003
|
+
event: 'context_compaction', taskId: this.taskId,
|
|
1004
|
+
kind: 'progressive', stages: compaction.stagesFired,
|
|
1005
|
+
reclaimedChars: compaction.totalReclaimed,
|
|
1006
|
+
finalChars: compaction.finalChars,
|
|
1007
|
+
limitChars: MAX_CHAR_POOL
|
|
1008
|
+
});
|
|
975
1009
|
}
|
|
976
1010
|
|
|
977
1011
|
const { window: slidingWindow, splitIndex } = buildWindow(this.messages, MAX_CHAR_POOL);
|
|
@@ -996,9 +1030,17 @@ export class AgentLoop {
|
|
|
996
1030
|
if (splitIndex >= 1) {
|
|
997
1031
|
const dropped = this.messages.slice(1, splitIndex + 1);
|
|
998
1032
|
if (dropped.length > 0) {
|
|
999
|
-
const
|
|
1033
|
+
const compactSummary = buildStructuralSummary(dropped) || describeDroppedMessages(dropped);
|
|
1034
|
+
const removed = compactInPlace(this.messages, splitIndex, compactSummary);
|
|
1000
1035
|
this._lastCompactionIndex = null;
|
|
1001
1036
|
this.debugLog(`compacted ${removed} messages out of the live context`);
|
|
1037
|
+
auditLog.log({
|
|
1038
|
+
event: 'context_compaction', taskId: this.taskId,
|
|
1039
|
+
kind: 'summary', removedMessages: removed,
|
|
1040
|
+
retainedSummaryChars: compactSummary.length,
|
|
1041
|
+
limitChars: MAX_CHAR_POOL
|
|
1042
|
+
});
|
|
1043
|
+
this.notice(`Context compacted: ${removed} earlier messages condensed into a reviewable summary.`);
|
|
1002
1044
|
|
|
1003
1045
|
// V5 Cortex tiered condensation. Tier 1 (structural, free) always
|
|
1004
1046
|
// runs so the Digital Brain keeps learning on every provider. Tier 2
|
|
@@ -1029,6 +1071,8 @@ export class AgentLoop {
|
|
|
1029
1071
|
unlockedTools: this.unlockedTools,
|
|
1030
1072
|
unlockOrder: this.unlockOrder,
|
|
1031
1073
|
grantAllTools: this.config.grantAllTools === true,
|
|
1074
|
+
allowedTools: this.sandbox.allowedTools,
|
|
1075
|
+
deniedTools: this.config.deniedTools,
|
|
1032
1076
|
residentOnly
|
|
1033
1077
|
});
|
|
1034
1078
|
// A delegated subagent runs with a declared toolScope. Enforcing it
|
|
@@ -1699,58 +1743,10 @@ export class AgentLoop {
|
|
|
1699
1743
|
|
|
1700
1744
|
this.consecutiveBlockedActions = 0; // Reset counter on allowed action
|
|
1701
1745
|
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
// old alias meant this consent gate never fired and multi-GB model
|
|
1705
|
-
// downloads ran unannounced.
|
|
1706
|
-
const isHeavyAiDownload = functionName === 'manage_local_dependencies' &&
|
|
1707
|
-
['pipInst', 'fetchBin', 'fetchMod'].includes(args.action);
|
|
1708
|
-
// A standing approval from "Allow always" lifts the gate for this
|
|
1709
|
-
// tool until the process restarts. It is checked HERE rather than
|
|
1710
|
-
|
|
1711
|
-
// asking and then auto-answering would still interrupt the user,
|
|
1712
|
-
// which is the entire thing they were trying to stop.
|
|
1713
|
-
//
|
|
1714
|
-
// Nothing else is bypassed: the sandbox has already run above, so a
|
|
1715
|
-
// FORBIDDEN command is still refused and a path outside the workspace
|
|
1716
|
-
// is still blocked. This waives the QUESTION, not the guard.
|
|
1717
|
-
const standingApproval = isAlwaysAllowed(functionName);
|
|
1718
|
-
if (standingApproval) {
|
|
1719
|
-
auditLog.log({
|
|
1720
|
-
event: 'approval_auto_granted', taskId: this.taskId,
|
|
1721
|
-
tool: functionName, risk: validation.risk, reason: 'allow_always'
|
|
1722
|
-
});
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
if (!standingApproval && (
|
|
1726
|
-
validation.risk === 'extreme' ||
|
|
1727
|
-
isHeavyAiDownload ||
|
|
1728
|
-
(this.config.autonomyMode !== 'C' && validation.risk === 'dangerous') ||
|
|
1729
|
-
this.config.autonomyMode === 'A')) {
|
|
1730
|
-
|
|
1731
|
-
// Wait for local approval
|
|
1732
|
-
this.emitEvent({ type: 'approval_request', toolCallId: toolCall.id, tool: functionName, args, risk: validation.risk });
|
|
1733
|
-
|
|
1734
|
-
const approved = await this.waitForApproval(toolCall.id);
|
|
1735
|
-
|
|
1736
|
-
if (!approved) {
|
|
1737
|
-
this.messages.push({
|
|
1738
|
-
role: 'tool',
|
|
1739
|
-
tool_call_id: toolCall.id,
|
|
1740
|
-
name: functionName,
|
|
1741
|
-
content: JSON.stringify({ success: false, error: 'User denied this action' })
|
|
1742
|
-
});
|
|
1743
|
-
continue;
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
// Execute
|
|
1746
|
+
// Resolve and validate before asking the user for approval. Unknown or
|
|
1747
|
+
// malformed calls should self-correct without creating approval fatigue.
|
|
1748
1748
|
const tool = resolveTool(functionName);
|
|
1749
|
-
|
|
1750
|
-
// GUARD: Prevent crash if LLM hallucinates a non-existent tool name
|
|
1751
1749
|
if (!tool || typeof tool.execute !== 'function') {
|
|
1752
|
-
// Only advertise what is currently in context. Listing all 69 names
|
|
1753
|
-
// here re-bloated the very context progressive disclosure protects.
|
|
1754
1750
|
this.messages.push({
|
|
1755
1751
|
role: 'tool',
|
|
1756
1752
|
tool_call_id: toolCall.id,
|
|
@@ -1763,10 +1759,6 @@ export class AgentLoop {
|
|
|
1763
1759
|
continue;
|
|
1764
1760
|
}
|
|
1765
1761
|
|
|
1766
|
-
// AUTO-UNLOCK RECOVERY: the model named a real tool whose pack is not
|
|
1767
|
-
// loaded yet (it remembered it from an earlier task, or guessed well).
|
|
1768
|
-
// Unlock the pack and run it rather than dead-ending — capability is
|
|
1769
|
-
// deferred, never removed.
|
|
1770
1762
|
if (!isCoreTool(functionName) && !this.unlockedTools.has(functionName)) {
|
|
1771
1763
|
const pack = packForTool(functionName);
|
|
1772
1764
|
if (this.unlockPack(pack)) {
|
|
@@ -1775,26 +1767,29 @@ export class AgentLoop {
|
|
|
1775
1767
|
this.unlockedTools.add(functionName);
|
|
1776
1768
|
}
|
|
1777
1769
|
|
|
1778
|
-
// V5: Tool Call Argument Validation Layer
|
|
1779
1770
|
const validationErrors = this.validateToolArgs(functionName, args);
|
|
1780
1771
|
if (validationErrors) {
|
|
1781
1772
|
this.messages.push({
|
|
1782
1773
|
role: 'tool',
|
|
1783
1774
|
tool_call_id: toolCall.id,
|
|
1784
1775
|
name: functionName,
|
|
1785
|
-
content: JSON.stringify({
|
|
1786
|
-
success: false,
|
|
1787
|
-
error: `Invalid arguments: ${validationErrors}. Please fix and retry.`
|
|
1776
|
+
content: JSON.stringify({
|
|
1777
|
+
success: false,
|
|
1778
|
+
error: `Invalid arguments: ${validationErrors}. Please fix and retry.`
|
|
1788
1779
|
})
|
|
1789
1780
|
});
|
|
1790
1781
|
continue;
|
|
1791
1782
|
}
|
|
1792
1783
|
|
|
1793
|
-
//
|
|
1794
|
-
//
|
|
1795
|
-
//
|
|
1784
|
+
// A policy hook runs after the built-in sandbox but before any user
|
|
1785
|
+
// approval prompt, so a deterministic denial never asks the user to
|
|
1786
|
+
// approve work that will be rejected anyway.
|
|
1796
1787
|
const preHook = await this.runPreToolUse(functionName, args, toolCall.id);
|
|
1797
1788
|
if (preHook && preHook.denied) {
|
|
1789
|
+
auditLog.log({
|
|
1790
|
+
event: 'hook_denied', taskId: this.taskId, hookEvent: 'PreToolUse',
|
|
1791
|
+
tool: functionName, reason: preHook.reason || 'hook denied'
|
|
1792
|
+
});
|
|
1798
1793
|
this.messages.push({
|
|
1799
1794
|
role: 'tool',
|
|
1800
1795
|
tool_call_id: toolCall.id,
|
|
@@ -1808,6 +1803,51 @@ export class AgentLoop {
|
|
|
1808
1803
|
continue;
|
|
1809
1804
|
}
|
|
1810
1805
|
|
|
1806
|
+
// Approval flow for dangerous actions (Mode A/B) or ALWAYS for heavy AI downloads
|
|
1807
|
+
// The schema name is manage_local_dependencies. Comparing against the
|
|
1808
|
+
// old alias meant this consent gate never fired and multi-GB model
|
|
1809
|
+
// downloads ran unannounced.
|
|
1810
|
+
const isHeavyAiDownload = functionName === 'manage_local_dependencies' &&
|
|
1811
|
+
['pipInst', 'fetchBin', 'fetchMod'].includes(args.action);
|
|
1812
|
+
// A standing approval from "Allow always" lifts the gate for this
|
|
1813
|
+
// tool until the process restarts. It is checked HERE rather than
|
|
1814
|
+
|
|
1815
|
+
// asking and then auto-answering would still interrupt the user,
|
|
1816
|
+
// which is the entire thing they were trying to stop.
|
|
1817
|
+
//
|
|
1818
|
+
// Nothing else is bypassed: the sandbox has already run above, so a
|
|
1819
|
+
// FORBIDDEN command is still refused and a path outside the workspace
|
|
1820
|
+
// is still blocked. This waives the QUESTION, not the guard.
|
|
1821
|
+
const standingApproval = isAlwaysAllowed(functionName);
|
|
1822
|
+
if (standingApproval) {
|
|
1823
|
+
auditLog.log({
|
|
1824
|
+
event: 'approval_auto_granted', taskId: this.taskId,
|
|
1825
|
+
tool: functionName, risk: validation.risk, reason: 'allow_always'
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
if (!standingApproval && (
|
|
1830
|
+
validation.risk === 'extreme' ||
|
|
1831
|
+
isHeavyAiDownload ||
|
|
1832
|
+
(this.config.autonomyMode !== 'C' && validation.risk === 'dangerous') ||
|
|
1833
|
+
this.config.autonomyMode === 'A')) {
|
|
1834
|
+
|
|
1835
|
+
// Wait for local approval
|
|
1836
|
+
this.emitEvent({ type: 'approval_request', toolCallId: toolCall.id, tool: functionName, args, risk: validation.risk });
|
|
1837
|
+
|
|
1838
|
+
const approved = await this.waitForApproval(toolCall.id);
|
|
1839
|
+
|
|
1840
|
+
if (!approved) {
|
|
1841
|
+
this.messages.push({
|
|
1842
|
+
role: 'tool',
|
|
1843
|
+
tool_call_id: toolCall.id,
|
|
1844
|
+
name: functionName,
|
|
1845
|
+
content: JSON.stringify({ success: false, error: 'User denied this action' })
|
|
1846
|
+
});
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1811
1851
|
// P2: snapshot the files this call is about to change, before it runs.
|
|
1812
1852
|
// Only the mutating fs tools match; everything else returns immediately.
|
|
1813
1853
|
// Fail-open by contract — this can never block or fail a tool call.
|
|
@@ -2393,26 +2433,32 @@ export class AgentLoop {
|
|
|
2393
2433
|
return hints[errorType] || hints['unknown_error'];
|
|
2394
2434
|
}
|
|
2395
2435
|
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
* plug into.
|
|
2407
|
-
*/
|
|
2408
|
-
async runPreToolUse(_toolName, _args, _toolCallId) {
|
|
2409
|
-
return { denied: false, messages: [] };
|
|
2436
|
+
async runPreToolUse(toolName, args, toolCallId) {
|
|
2437
|
+
return runConfiguredHooks({
|
|
2438
|
+
hooks: this.config?.hooks,
|
|
2439
|
+
event: 'PreToolUse',
|
|
2440
|
+
toolName,
|
|
2441
|
+
args,
|
|
2442
|
+
taskId: this.taskId,
|
|
2443
|
+
toolCallId,
|
|
2444
|
+
workspacePaths: this.sandbox?.workspacePaths
|
|
2445
|
+
});
|
|
2410
2446
|
}
|
|
2411
2447
|
|
|
2412
|
-
async runPostToolUse(
|
|
2413
|
-
return {
|
|
2448
|
+
async runPostToolUse(toolName, args, result) {
|
|
2449
|
+
return runConfiguredHooks({
|
|
2450
|
+
hooks: this.config?.hooks,
|
|
2451
|
+
event: 'PostToolUse',
|
|
2452
|
+
toolName,
|
|
2453
|
+
args,
|
|
2454
|
+
result,
|
|
2455
|
+
taskId: this.taskId,
|
|
2456
|
+
workspacePaths: this.sandbox?.workspacePaths
|
|
2457
|
+
});
|
|
2414
2458
|
}
|
|
2415
2459
|
|
|
2460
|
+
// V5: Validate tool call arguments before execution
|
|
2461
|
+
|
|
2416
2462
|
validateToolArgs(toolName, args) {
|
|
2417
2463
|
// Check for common LLM mistakes
|
|
2418
2464
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { performance } from 'node:perf_hooks';
|
|
7
|
+
|
|
8
|
+
// Never inspect or mutate a user's real plan cache. utils/paths.js resolves the
|
|
9
|
+
// state root at module initialization, so set it before importing planCache.js.
|
|
10
|
+
const benchmarkHome = fs.mkdtempSync(path.join(os.tmpdir(), 'causyne-plan-cache-bench-'));
|
|
11
|
+
process.env.CAUSYNE_HOME = benchmarkHome;
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
forgetPlans,
|
|
15
|
+
lookupPlan,
|
|
16
|
+
planStats,
|
|
17
|
+
prewarmPlans
|
|
18
|
+
} = await import('../planCache.js');
|
|
19
|
+
|
|
20
|
+
const sizes = [1, 10, 20, 50, 100, 200];
|
|
21
|
+
const hitIterations = 500;
|
|
22
|
+
// Production deliberately cools down after a sustained low hit rate. Staying
|
|
23
|
+
// below that threshold measures a real miss scan instead of the cheap bypass.
|
|
24
|
+
const missIterations = 16;
|
|
25
|
+
|
|
26
|
+
function sample(index) {
|
|
27
|
+
const key = `taskkey${String(index).padStart(3, '0')}`;
|
|
28
|
+
return {
|
|
29
|
+
prompt: `benchmark workflow ${key} inspect validate artifact`,
|
|
30
|
+
archetype: 'benchmark',
|
|
31
|
+
steps: [
|
|
32
|
+
{ tool: 'fs_search', action: 'inspect', ok: true },
|
|
33
|
+
{ tool: 'sys_run', action: 'validate', ok: true }
|
|
34
|
+
]
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function percentile(values, fraction) {
|
|
39
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
40
|
+
if (sorted.length === 0) return 0;
|
|
41
|
+
return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * fraction))];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function measure(iterations, operation) {
|
|
45
|
+
const values = [];
|
|
46
|
+
for (let index = 0; index < iterations; index++) {
|
|
47
|
+
const startedAt = performance.now();
|
|
48
|
+
operation(index);
|
|
49
|
+
values.push(performance.now() - startedAt);
|
|
50
|
+
}
|
|
51
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
52
|
+
return {
|
|
53
|
+
iterations,
|
|
54
|
+
averageMs: total / values.length,
|
|
55
|
+
medianMs: percentile(values, 0.5),
|
|
56
|
+
p95Ms: percentile(values, 0.95),
|
|
57
|
+
maxMs: Math.max(...values)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fixed(value) {
|
|
62
|
+
return Number(value.toFixed(4));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function compactMeasurement(measurement) {
|
|
66
|
+
return {
|
|
67
|
+
iterations: measurement.iterations,
|
|
68
|
+
averageMs: fixed(measurement.averageMs),
|
|
69
|
+
medianMs: fixed(measurement.medianMs),
|
|
70
|
+
p95Ms: fixed(measurement.p95Ms),
|
|
71
|
+
maxMs: fixed(measurement.maxMs)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function prewarmInBatches(seeds) {
|
|
76
|
+
const combined = { accepted: 0, rejected: 0, errors: [] };
|
|
77
|
+
for (let offset = 0; offset < seeds.length; offset += 100) {
|
|
78
|
+
const batch = prewarmPlans(seeds.slice(offset, offset + 100));
|
|
79
|
+
combined.accepted += batch.accepted;
|
|
80
|
+
combined.rejected += batch.rejected;
|
|
81
|
+
combined.errors.push(...batch.errors);
|
|
82
|
+
}
|
|
83
|
+
return combined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const results = [];
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
for (const size of sizes) {
|
|
90
|
+
forgetPlans();
|
|
91
|
+
const seeds = Array.from({ length: size }, (_, index) => sample(index));
|
|
92
|
+
const prewarmStartedAt = performance.now();
|
|
93
|
+
const prewarm = prewarmInBatches(seeds);
|
|
94
|
+
const prewarmMs = performance.now() - prewarmStartedAt;
|
|
95
|
+
const target = sample(Math.floor(size / 2));
|
|
96
|
+
|
|
97
|
+
// Warm filesystem and in-memory index before measuring lookup paths.
|
|
98
|
+
lookupPlan({ prompt: target.prompt, archetype: target.archetype, allowFuzzy: true });
|
|
99
|
+
|
|
100
|
+
const exact = measure(hitIterations, () => {
|
|
101
|
+
const match = lookupPlan({
|
|
102
|
+
prompt: target.prompt,
|
|
103
|
+
archetype: target.archetype,
|
|
104
|
+
allowFuzzy: true
|
|
105
|
+
});
|
|
106
|
+
if (!match || match.matchType !== 'exact') {
|
|
107
|
+
throw new Error(`Exact lookup failed at cache size ${size}`);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const fuzzyPrompt = target.prompt.replace('validate artifact', 'validate repaired artifact');
|
|
112
|
+
const fuzzy = measure(hitIterations, () => {
|
|
113
|
+
const match = lookupPlan({
|
|
114
|
+
prompt: fuzzyPrompt,
|
|
115
|
+
archetype: target.archetype,
|
|
116
|
+
allowFuzzy: true
|
|
117
|
+
});
|
|
118
|
+
if (!match || match.matchType !== 'fuzzy') {
|
|
119
|
+
throw new Error(`Fuzzy lookup failed at cache size ${size}`);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const miss = measure(missIterations, (index) => {
|
|
124
|
+
const match = lookupPlan({
|
|
125
|
+
prompt: `unrelated cold miss ${size} ${index} quantum orchard telescope`,
|
|
126
|
+
archetype: 'different-archetype',
|
|
127
|
+
allowFuzzy: true
|
|
128
|
+
});
|
|
129
|
+
if (match) throw new Error(`Unexpected cache hit at cache size ${size}`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
results.push({
|
|
133
|
+
cacheSize: size,
|
|
134
|
+
acceptedSeeds: prewarm.accepted,
|
|
135
|
+
prewarmMs: fixed(prewarmMs),
|
|
136
|
+
exact: compactMeasurement(exact),
|
|
137
|
+
fuzzy: compactMeasurement(fuzzy),
|
|
138
|
+
miss: compactMeasurement(miss),
|
|
139
|
+
telemetry: planStats()
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const report = {
|
|
144
|
+
benchmark: 'Causyne Agent plan cache lookup',
|
|
145
|
+
generatedAt: new Date().toISOString(),
|
|
146
|
+
runtime: { node: process.version, platform: process.platform, arch: process.arch },
|
|
147
|
+
method: {
|
|
148
|
+
cacheSizes: sizes,
|
|
149
|
+
hitIterations,
|
|
150
|
+
missIterations,
|
|
151
|
+
note: 'Synthetic microbenchmark. It measures Causyne cache operations only; it does not reproduce paper task accuracy, model cost, or end-to-end agent latency.'
|
|
152
|
+
},
|
|
153
|
+
results
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
157
|
+
} finally {
|
|
158
|
+
fs.rmSync(benchmarkHome, { recursive: true, force: true });
|
|
159
|
+
}
|
package/src/commands.js
CHANGED
|
@@ -425,6 +425,89 @@ export const COMMANDS = [
|
|
|
425
425
|
}
|
|
426
426
|
},
|
|
427
427
|
|
|
428
|
+
{
|
|
429
|
+
name: 'plans',
|
|
430
|
+
scope: 'local',
|
|
431
|
+
usage: '/plans [import <json-file> | forget]',
|
|
432
|
+
description: 'Inspect, pre-warm, or clear reusable task plans',
|
|
433
|
+
dialogTitle: 'Plan cache',
|
|
434
|
+
run: async (args = []) => {
|
|
435
|
+
const cache = await import('./planCache.js');
|
|
436
|
+
const verb = String(args[0] || '').toLowerCase();
|
|
437
|
+
|
|
438
|
+
if (verb === 'forget') {
|
|
439
|
+
const removed = cache.forgetPlans();
|
|
440
|
+
return ok(`Forgot ${removed} cached plan${removed === 1 ? '' : 's'}.`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (verb === 'import') {
|
|
444
|
+
const requested = args.slice(1).join(' ').trim();
|
|
445
|
+
if (!requested) return fail('Usage: /plans import <json-file>');
|
|
446
|
+
const file = path.resolve(requested.replace(/^~(?=\/)/, os.homedir()));
|
|
447
|
+
try {
|
|
448
|
+
const stat = fs.statSync(file);
|
|
449
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) {
|
|
450
|
+
return fail('Plan seed must be one JSON file no larger than 1 MB.');
|
|
451
|
+
}
|
|
452
|
+
const outcome = cache.prewarmPlans(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
453
|
+
const detail = outcome.errors.length > 0 ? `\n${outcome.errors.join('\n')}` : '';
|
|
454
|
+
return outcome.accepted > 0
|
|
455
|
+
? ok(`Imported ${outcome.accepted} plan${outcome.accepted === 1 ? '' : 's'}; rejected ${outcome.rejected}.${detail}`)
|
|
456
|
+
: fail(`No plans were imported.${detail}`);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
return fail(`Could not import plan seeds: ${error.message}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const stats = cache.planStats();
|
|
463
|
+
return ok([
|
|
464
|
+
`${stats.count} cached plan${stats.count === 1 ? '' : 's'}`,
|
|
465
|
+
`Hits: ${stats.hits}/${stats.lookups} (${(stats.hitRate * 100).toFixed(1)}%) — exact ${stats.exactHits}, fuzzy ${stats.fuzzyHits}`,
|
|
466
|
+
`Average lookup: ${stats.averageLookupMs.toFixed(3)} ms; max ${stats.maxLookupMs.toFixed(3)} ms`,
|
|
467
|
+
stats.disabled
|
|
468
|
+
? `Lookup cooling down until ${new Date(stats.disabledUntil).toISOString()} after persistent misses.`
|
|
469
|
+
: 'Lookup active.',
|
|
470
|
+
'',
|
|
471
|
+
'Pre-warm with /plans import <json-file>. Clear with /plans forget.'
|
|
472
|
+
].join('\n'));
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
{
|
|
477
|
+
name: 'plugins',
|
|
478
|
+
scope: 'local',
|
|
479
|
+
usage: '/plugins [enable <name> | disable <name>]',
|
|
480
|
+
description: 'Inspect or change trust for local plugins',
|
|
481
|
+
dialogTitle: 'Plugins',
|
|
482
|
+
run: async (args = []) => {
|
|
483
|
+
const manager = await import('./plugins/pluginManager.js');
|
|
484
|
+
const verb = String(args[0] || '').toLowerCase();
|
|
485
|
+
const name = String(args[1] || '');
|
|
486
|
+
if (verb === 'enable') {
|
|
487
|
+
if (!name) return fail('Usage: /plugins enable <name>');
|
|
488
|
+
const result = manager.enablePlugin(name);
|
|
489
|
+
return result.ok
|
|
490
|
+
? ok(`Enabled ${name} at manifest digest ${result.digest}. Changes will revoke trust automatically.`)
|
|
491
|
+
: fail(`Could not enable ${name}: ${result.error}`);
|
|
492
|
+
}
|
|
493
|
+
if (verb === 'disable') {
|
|
494
|
+
if (!name) return fail('Usage: /plugins disable <name>');
|
|
495
|
+
const result = manager.disablePlugin(name);
|
|
496
|
+
return result.ok ? ok(`Disabled ${name}.`) : fail(`Could not disable ${name}: ${result.error}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const rows = manager.listPlugins();
|
|
500
|
+
if (rows.length === 0) return ok(`No local plugins. Plugin root: ${manager.PLUGIN_ROOT}`);
|
|
501
|
+
return ok(rows.map((plugin) => {
|
|
502
|
+
const state = plugin.invalid ? 'invalid' : plugin.modified ? 'modified; trust revoked' : plugin.enabled ? 'enabled' : 'disabled';
|
|
503
|
+
const capabilities = Array.isArray(plugin.capabilities) && plugin.capabilities.length > 0
|
|
504
|
+
? plugin.capabilities.join(', ')
|
|
505
|
+
: 'none';
|
|
506
|
+
return ` ${plugin.name} ${plugin.version || ''} — ${state}; capabilities: ${capabilities}`;
|
|
507
|
+
}).join('\n') + `\n\nPlugin root: ${manager.PLUGIN_ROOT}`);
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
|
|
428
511
|
{
|
|
429
512
|
name: 'sessions',
|
|
430
513
|
aliases: ['session'],
|