@causyne/agent 1.10.12 → 1.10.17

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.
Files changed (68) hide show
  1. package/LICENSE +1 -1
  2. package/docs/ARCHITECTURE.md +136 -7
  3. package/docs/SECURITY_REVIEW.md +13 -1
  4. package/docs/THIRD_PARTY_NOTICES.md +43 -10
  5. package/package.json +11 -4
  6. package/src/agentLoop.js +161 -93
  7. package/src/agentProfiles.js +135 -0
  8. package/src/agentProfiles.ts +143 -0
  9. package/src/approvalGrants.js +17 -35
  10. package/src/approvalGrants.ts +54 -0
  11. package/src/auditLog.d.ts +3 -0
  12. package/src/benchmarks/planCache.js +159 -0
  13. package/src/browser/camofoxRuntime.js +93 -0
  14. package/src/browser/camofoxRuntime.ts +112 -0
  15. package/src/browser/causyneBrowser.js +495 -0
  16. package/src/browser/causyneBrowser.ts +594 -0
  17. package/src/browser/searchProviders.js +156 -0
  18. package/src/browser/searchProviders.ts +156 -0
  19. package/src/browser/snapshotWindow.js +28 -0
  20. package/src/browser/snapshotWindow.ts +40 -0
  21. package/src/browser/urlPolicy.js +89 -0
  22. package/src/browser/urlPolicy.ts +89 -0
  23. package/src/checkpoints.js +19 -6
  24. package/src/commands.js +120 -8
  25. package/src/config.d.ts +2 -0
  26. package/src/cortex/autoCondenser.js +1 -1
  27. package/src/hooks.js +150 -0
  28. package/src/hooks.ts +193 -0
  29. package/src/mcp/mcpClient.js +520 -132
  30. package/src/mcp/mcpClient.ts +584 -0
  31. package/src/mcp/mcpRegistry.js +399 -185
  32. package/src/mcp/mcpRegistry.ts +451 -0
  33. package/src/planCache.js +529 -225
  34. package/src/planCache.ts +840 -0
  35. package/src/plugins/pluginManager.js +460 -0
  36. package/src/plugins/pluginRunner.js +50 -0
  37. package/src/plugins/pluginSkills.js +84 -0
  38. package/src/plugins/pluginSkills.ts +106 -0
  39. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  40. package/src/provisioning/capabilityProvisioner.js +5 -0
  41. package/src/provisioning/capabilityTools.js +2 -1
  42. package/src/sandbox.js +53 -2
  43. package/src/sessionJournal.js +471 -0
  44. package/src/sessionJournal.ts +496 -0
  45. package/src/sessionStore.js +3 -279
  46. package/src/skillsManager.js +18 -0
  47. package/src/subagentJournal.js +215 -0
  48. package/src/subagentJournal.ts +254 -0
  49. package/src/taskShape.js +179 -198
  50. package/src/taskShape.ts +259 -0
  51. package/src/toolPacks.js +6 -3
  52. package/src/tools/browserAutomator.js +45 -241
  53. package/src/tools/browserAutomator.ts +57 -0
  54. package/src/tools/deferredMcpSearch.js +2 -2
  55. package/src/tools/manageLocalAiDependencies.js +1 -1
  56. package/src/tools/pluginManager.js +61 -0
  57. package/src/tools/registry.js +47 -53
  58. package/src/tools/subagentOrchestrator.js +73 -12
  59. package/src/tools/toolForge.js +47 -95
  60. package/src/tools/webFetch.js +183 -68
  61. package/src/tools/webFetch.ts +185 -0
  62. package/src/tools/webSearch.js +50 -75
  63. package/src/tools/webSearch.ts +56 -0
  64. package/src/types/contracts.js +8 -0
  65. package/src/types/contracts.ts +263 -0
  66. package/src/utils/paths.d.ts +3 -0
  67. package/src/version.d.ts +1 -0
  68. package/src/mcp/jsonRpc.js +0 -165
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';
@@ -343,6 +344,11 @@ export class AgentLoop {
343
344
  this.circuitBreakerUnlocked = false;
344
345
  this.stopRequested = false;
345
346
  this.pendingApprovals = new Map(); // Maps toolCallId -> resolve function
347
+ // A delegated subagent can tighten these two ceilings. They are applied
348
+ // after every config refresh so run() cannot accidentally replace a
349
+ // worker's declared bounds with the parent tier defaults.
350
+ this.maxStepsCeiling = null;
351
+ this.taskTokenLimit = null;
346
352
 
347
353
  // Limits based on tier/budget & emulator fast bypass
348
354
  this.updateTierLimits();
@@ -497,18 +503,9 @@ export class AgentLoop {
497
503
  // once per task rather than once per step.
498
504
  let planHint = '';
499
505
  try {
500
- const match = lookupPlan({
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
- }
506
+ planHint = renderPlanHint(this._planMatch);
510
507
  } catch (e) {
511
- this.debugLog(`plan cache lookup skipped: ${e.message}`);
508
+ this.debugLog(`plan cache hint skipped: ${e.message}`);
512
509
  }
513
510
 
514
511
  return { lessonsLearned, designContext, antiRepeatDirective, universalSkillsContext, planHint };
@@ -601,6 +598,10 @@ export class AgentLoop {
601
598
  for (const toolCall of toolCalls) {
602
599
  const functionName = canonicalToolName(toolCall.function.name);
603
600
  if (!AgentLoop.READ_ONLY_TOOLS.has(functionName)) continue;
601
+ // Prefetch begins before the serial dispatch path. If this tool has a
602
+ // PreToolUse hook, running it here would create a temporal pre-trust
603
+ // window where the read happens before the hook can deny it.
604
+ if (hasMatchingHook(this.config.hooks, 'PreToolUse', functionName)) continue;
604
605
 
605
606
  let args;
606
607
  try {
@@ -723,6 +724,17 @@ export class AgentLoop {
723
724
  } else {
724
725
  this.maxSteps = 300;
725
726
  }
727
+ if (Number.isInteger(this.maxStepsCeiling) && this.maxStepsCeiling > 0) {
728
+ this.maxSteps = Math.min(this.maxSteps, this.maxStepsCeiling);
729
+ }
730
+ if (Number.isInteger(this.taskTokenLimit) && this.taskTokenLimit > 0 && this.tokenTracker) {
731
+ this.tokenTracker.budget = {
732
+ ...this.tokenTracker.budget,
733
+ enabled: true,
734
+ hardStop: true,
735
+ maxTokensPerTask: this.taskTokenLimit
736
+ };
737
+ }
726
738
  this.maxCircuitBreaker = this.maxSteps;
727
739
  }
728
740
 
@@ -800,6 +812,7 @@ export class AgentLoop {
800
812
  this._planSteps = [];
801
813
  this._offeredPlanId = null;
802
814
  this._planSimilarity = 0;
815
+ this._planMatch = null;
803
816
  this._lessonSimilarity = 0;
804
817
  this._lastToolResult = null;
805
818
  this._paradigmShiftPending = false;
@@ -816,6 +829,36 @@ export class AgentLoop {
816
829
  this.unlockPack(pack);
817
830
  }
818
831
 
832
+ // Reuse a known route before the first model call. Exact keyword-set
833
+ // matches are always eligible; fuzzy reuse requires at least medium task
834
+ // classification confidence because APC's threshold study shows that
835
+ // aggressive fuzzy hits trade accuracy for speed.
836
+ try {
837
+ this._planMatch = lookupPlan({
838
+ prompt: this.userPrompt,
839
+ archetype: this.taskShape ? this.taskShape.archetype : '',
840
+ allowFuzzy: this.taskShape?.confidence !== 'low'
841
+ });
842
+ if (this._planMatch) {
843
+ this._offeredPlanId = this._planMatch.template.id;
844
+ this._planSimilarity = this._planMatch.similarity;
845
+ for (const step of this._planMatch.template.steps || []) {
846
+ if (!step.ok || !resolveTool(step.tool)) continue;
847
+ this.unlockedTools.add(step.tool);
848
+ const pack = packForTool(step.tool);
849
+ if (pack) this.unlockPack(pack);
850
+ }
851
+ this.debugLog(
852
+ `plan cache ${this._planMatch.matchType || 'fuzzy'} hit `
853
+ + `${(this._planMatch.similarity * 100).toFixed(0)}% `
854
+ + `(${this._planMatch.template.steps.length} steps, `
855
+ + `${this._planMatch.telemetry?.lookupMs?.toFixed(2) || '0.00'} ms)`
856
+ );
857
+ }
858
+ } catch (e) {
859
+ this.debugLog(`plan cache lookup skipped: ${e.message}`);
860
+ }
861
+
819
862
  this.debugLog(`task shape: ${this.taskShape.archetype} (${this.taskShape.confidence} confidence)`);
820
863
  }
821
864
 
@@ -972,6 +1015,13 @@ export class AgentLoop {
972
1015
  });
973
1016
  if (compaction.totalReclaimed > 0) {
974
1017
  this.debugLog(`progressive compaction reclaimed ${(compaction.totalReclaimed / 1024).toFixed(1)} KB [${compaction.stagesFired.join(', ')}]`);
1018
+ auditLog.log({
1019
+ event: 'context_compaction', taskId: this.taskId,
1020
+ kind: 'progressive', stages: compaction.stagesFired,
1021
+ reclaimedChars: compaction.totalReclaimed,
1022
+ finalChars: compaction.finalChars,
1023
+ limitChars: MAX_CHAR_POOL
1024
+ });
975
1025
  }
976
1026
 
977
1027
  const { window: slidingWindow, splitIndex } = buildWindow(this.messages, MAX_CHAR_POOL);
@@ -996,9 +1046,17 @@ export class AgentLoop {
996
1046
  if (splitIndex >= 1) {
997
1047
  const dropped = this.messages.slice(1, splitIndex + 1);
998
1048
  if (dropped.length > 0) {
999
- const removed = compactInPlace(this.messages, splitIndex, describeDroppedMessages(dropped));
1049
+ const compactSummary = buildStructuralSummary(dropped) || describeDroppedMessages(dropped);
1050
+ const removed = compactInPlace(this.messages, splitIndex, compactSummary);
1000
1051
  this._lastCompactionIndex = null;
1001
1052
  this.debugLog(`compacted ${removed} messages out of the live context`);
1053
+ auditLog.log({
1054
+ event: 'context_compaction', taskId: this.taskId,
1055
+ kind: 'summary', removedMessages: removed,
1056
+ retainedSummaryChars: compactSummary.length,
1057
+ limitChars: MAX_CHAR_POOL
1058
+ });
1059
+ this.notice(`Context compacted: ${removed} earlier messages condensed into a reviewable summary.`);
1002
1060
 
1003
1061
  // V5 Cortex tiered condensation. Tier 1 (structural, free) always
1004
1062
  // runs so the Digital Brain keeps learning on every provider. Tier 2
@@ -1029,6 +1087,8 @@ export class AgentLoop {
1029
1087
  unlockedTools: this.unlockedTools,
1030
1088
  unlockOrder: this.unlockOrder,
1031
1089
  grantAllTools: this.config.grantAllTools === true,
1090
+ allowedTools: this.sandbox.allowedTools,
1091
+ deniedTools: this.config.deniedTools,
1032
1092
  residentOnly
1033
1093
  });
1034
1094
  // A delegated subagent runs with a declared toolScope. Enforcing it
@@ -1477,18 +1537,24 @@ export class AgentLoop {
1477
1537
  // if it was declined, fall through to the path that needs nothing.
1478
1538
  let visionReady = false;
1479
1539
  let visionDeclined = false;
1540
+ let browserReady = false;
1541
+ let browserDeclined = false;
1480
1542
  try {
1481
1543
  const { groupStatus, isGroupDeclined } = await import('./provisioning/capabilityProvisioner.js');
1482
1544
  visionReady = groupStatus('vision').state === 'ready';
1483
1545
  visionDeclined = isGroupDeclined('vision');
1546
+ browserReady = groupStatus('browser').state === 'ready';
1547
+ browserDeclined = isGroupDeclined('browser');
1484
1548
  } catch (e) { /* treat as not ready */ }
1485
1549
  let webGuardMsg = '';
1486
1550
 
1487
- if (hasVision && !visionReady && !visionDeclined) {
1488
- webGuardMsg = `[ANTI-SLOP CHECK: You are building a Web/UI project and must look at it before finishing, but the browser and screen-capture capability is not enabled yet. Call \`manage_local_dependencies\` with action "grant_capability" and group "vision" to request it from the user. If the user declines, read the final compiled HTML/CSS instead and verify it that way. This check fires once.]`;
1551
+ if (!browserReady && !browserDeclined) {
1552
+ webGuardMsg = `[ANTI-SLOP CHECK: You are building a Web/UI project and must inspect it before finishing, but the dedicated browser capability is not enabled yet. Call \`manage_local_dependencies\` with action "grant_capability" and group "browser" to request the Causyne Camoufox runtime. If the user declines, read the final compiled HTML/CSS instead. This check fires once.]`;
1553
+ } else if (hasVision && !visionReady && !visionDeclined) {
1554
+ webGuardMsg = `[ANTI-SLOP CHECK: The browser is available, but desktop screen capture is not enabled. Call \`manage_local_dependencies\` with action "grant_capability" and group "vision" to request it, or use \`browser_act\` snapshots and its workspace-scoped screenshot action. This check fires once.]`;
1489
1555
  } else if (hasVision && visionReady && !this.webVerificationState.screenshotTaken) {
1490
1556
  webGuardMsg = `[ANTI-SLOP CHECK: You are building a Web/UI project but attempted to finish without VISUALLY verifying your work. Use \`screenshot\` or \`vision_inspect_ui\` to look at the UI in a browser. A human looks at their work to avoid slop. This check fires once — if there is genuinely nothing visual to inspect, say so plainly and finish.]`;
1491
- } else if ((!hasVision || visionDeclined) && !this.webVerificationState.browserActUsed && !this.webVerificationState.screenshotTaken) {
1557
+ } else if ((!hasVision || visionDeclined) && browserReady && !this.webVerificationState.browserActUsed && !this.webVerificationState.screenshotTaken) {
1492
1558
  webGuardMsg = `[TRANSCENDENCE CHECK: You are building a Web/UI project. As a vision-disabled model you cannot screenshot, but you MUST use \`browser_act\` to inspect the DOM, or read the final compiled HTML/CSS, to verify semantic correctness before finishing. This check fires once.]`;
1493
1559
  }
1494
1560
 
@@ -1699,58 +1765,10 @@ export class AgentLoop {
1699
1765
 
1700
1766
  this.consecutiveBlockedActions = 0; // Reset counter on allowed action
1701
1767
 
1702
- // Approval flow for dangerous actions (Mode A/B) or ALWAYS for heavy AI downloads
1703
- // The schema name is manage_local_dependencies. Comparing against the
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
1768
+ // Resolve and validate before asking the user for approval. Unknown or
1769
+ // malformed calls should self-correct without creating approval fatigue.
1748
1770
  const tool = resolveTool(functionName);
1749
-
1750
- // GUARD: Prevent crash if LLM hallucinates a non-existent tool name
1751
1771
  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
1772
  this.messages.push({
1755
1773
  role: 'tool',
1756
1774
  tool_call_id: toolCall.id,
@@ -1763,10 +1781,6 @@ export class AgentLoop {
1763
1781
  continue;
1764
1782
  }
1765
1783
 
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
1784
  if (!isCoreTool(functionName) && !this.unlockedTools.has(functionName)) {
1771
1785
  const pack = packForTool(functionName);
1772
1786
  if (this.unlockPack(pack)) {
@@ -1775,26 +1789,29 @@ export class AgentLoop {
1775
1789
  this.unlockedTools.add(functionName);
1776
1790
  }
1777
1791
 
1778
- // V5: Tool Call Argument Validation Layer
1779
1792
  const validationErrors = this.validateToolArgs(functionName, args);
1780
1793
  if (validationErrors) {
1781
1794
  this.messages.push({
1782
1795
  role: 'tool',
1783
1796
  tool_call_id: toolCall.id,
1784
1797
  name: functionName,
1785
- content: JSON.stringify({
1786
- success: false,
1787
- error: `Invalid arguments: ${validationErrors}. Please fix and retry.`
1798
+ content: JSON.stringify({
1799
+ success: false,
1800
+ error: `Invalid arguments: ${validationErrors}. Please fix and retry.`
1788
1801
  })
1789
1802
  });
1790
1803
  continue;
1791
1804
  }
1792
1805
 
1793
- // Stage 3 seam: PreToolUse hook. A no-op that always allows today; when
1794
- // hooks are configured it can deny a call before it runs, returned as an
1795
- // is_error tool result so the model self-corrects (never thrown).
1806
+ // A policy hook runs after the built-in sandbox but before any user
1807
+ // approval prompt, so a deterministic denial never asks the user to
1808
+ // approve work that will be rejected anyway.
1796
1809
  const preHook = await this.runPreToolUse(functionName, args, toolCall.id);
1797
1810
  if (preHook && preHook.denied) {
1811
+ auditLog.log({
1812
+ event: 'hook_denied', taskId: this.taskId, hookEvent: 'PreToolUse',
1813
+ tool: functionName, reason: preHook.reason || 'hook denied'
1814
+ });
1798
1815
  this.messages.push({
1799
1816
  role: 'tool',
1800
1817
  tool_call_id: toolCall.id,
@@ -1808,6 +1825,51 @@ export class AgentLoop {
1808
1825
  continue;
1809
1826
  }
1810
1827
 
1828
+ // Approval flow for dangerous actions (Mode A/B) or ALWAYS for heavy AI downloads
1829
+ // The schema name is manage_local_dependencies. Comparing against the
1830
+ // old alias meant this consent gate never fired and multi-GB model
1831
+ // downloads ran unannounced.
1832
+ const isHeavyAiDownload = functionName === 'manage_local_dependencies' &&
1833
+ ['pipInst', 'fetchBin', 'fetchMod'].includes(args.action);
1834
+ // A standing approval from "Allow always" lifts the gate for this
1835
+ // tool until the process restarts. It is checked HERE rather than
1836
+
1837
+ // asking and then auto-answering would still interrupt the user,
1838
+ // which is the entire thing they were trying to stop.
1839
+ //
1840
+ // Nothing else is bypassed: the sandbox has already run above, so a
1841
+ // FORBIDDEN command is still refused and a path outside the workspace
1842
+ // is still blocked. This waives the QUESTION, not the guard.
1843
+ const standingApproval = isAlwaysAllowed(functionName);
1844
+ if (standingApproval) {
1845
+ auditLog.log({
1846
+ event: 'approval_auto_granted', taskId: this.taskId,
1847
+ tool: functionName, risk: validation.risk, reason: 'allow_always'
1848
+ });
1849
+ }
1850
+
1851
+ if (!standingApproval && (
1852
+ validation.risk === 'extreme' ||
1853
+ isHeavyAiDownload ||
1854
+ (this.config.autonomyMode !== 'C' && validation.risk === 'dangerous') ||
1855
+ this.config.autonomyMode === 'A')) {
1856
+
1857
+ // Wait for local approval
1858
+ this.emitEvent({ type: 'approval_request', toolCallId: toolCall.id, tool: functionName, args, risk: validation.risk });
1859
+
1860
+ const approved = await this.waitForApproval(toolCall.id);
1861
+
1862
+ if (!approved) {
1863
+ this.messages.push({
1864
+ role: 'tool',
1865
+ tool_call_id: toolCall.id,
1866
+ name: functionName,
1867
+ content: JSON.stringify({ success: false, error: 'User denied this action' })
1868
+ });
1869
+ continue;
1870
+ }
1871
+ }
1872
+
1811
1873
  // P2: snapshot the files this call is about to change, before it runs.
1812
1874
  // Only the mutating fs tools match; everything else returns immediately.
1813
1875
  // Fail-open by contract — this can never block or fail a tool call.
@@ -2393,26 +2455,32 @@ export class AgentLoop {
2393
2455
  return hints[errorType] || hints['unknown_error'];
2394
2456
  }
2395
2457
 
2396
- // V5: Validate tool call arguments before execution
2397
- /**
2398
- * PreToolUse / PostToolUse seams (Stage 3 fills these in).
2399
- *
2400
- * Today they are no-ops that always allow, so the dispatch behaviour is exactly
2401
- * what it was. When `.causyne/settings.json` gains a `hooks` block (Stage 3),
2402
- * these run the user's shell hooks with the exit-code protocol
2403
- * (0 allow / 2 deny / else warn) and return { denied, messages }. The loop
2404
- * already branches on `denied`, so Stage 3 is a fill-in here, not another edit
2405
- * to the dispatch path. See src/tools/toolDispatch.js for the discipline these
2406
- * plug into.
2407
- */
2408
- async runPreToolUse(_toolName, _args, _toolCallId) {
2409
- return { denied: false, messages: [] };
2458
+ async runPreToolUse(toolName, args, toolCallId) {
2459
+ return runConfiguredHooks({
2460
+ hooks: this.config?.hooks,
2461
+ event: 'PreToolUse',
2462
+ toolName,
2463
+ args,
2464
+ taskId: this.taskId,
2465
+ toolCallId,
2466
+ workspacePaths: this.sandbox?.workspacePaths
2467
+ });
2410
2468
  }
2411
2469
 
2412
- async runPostToolUse(_toolName, _args, _result) {
2413
- return { denied: false, messages: [] };
2470
+ async runPostToolUse(toolName, args, result) {
2471
+ return runConfiguredHooks({
2472
+ hooks: this.config?.hooks,
2473
+ event: 'PostToolUse',
2474
+ toolName,
2475
+ args,
2476
+ result,
2477
+ taskId: this.taskId,
2478
+ workspacePaths: this.sandbox?.workspacePaths
2479
+ });
2414
2480
  }
2415
2481
 
2482
+ // V5: Validate tool call arguments before execution
2483
+
2416
2484
  validateToolArgs(toolName, args) {
2417
2485
  // Check for common LLM mistakes
2418
2486
 
@@ -0,0 +1,135 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { causyneHome } from './utils/paths.js';
4
+ const PROFILE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
5
+ const TOOL_NAME = /^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/;
6
+ const MAX_PROFILES = 64;
7
+ const MAX_PROFILE_BYTES = 24 * 1024;
8
+ const MAX_INSTRUCTIONS = 16 * 1024;
9
+ const MAX_TOOLS = 64;
10
+ export function agentProfilesRoot() {
11
+ return path.join(causyneHome(), 'agents');
12
+ }
13
+ function cleanLine(value, maximum) {
14
+ return String(value ?? '')
15
+ // eslint-disable-next-line no-control-regex
16
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
17
+ .replace(/\s+/g, ' ')
18
+ .trim()
19
+ .slice(0, maximum);
20
+ }
21
+ function cleanInstructions(value) {
22
+ return String(value ?? '')
23
+ // Preserve newlines and tabs while excluding prompt-control bytes.
24
+ // eslint-disable-next-line no-control-regex
25
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
26
+ .trim();
27
+ }
28
+ function boundedInteger(value, fallback, minimum, maximum) {
29
+ if (value === undefined)
30
+ return fallback;
31
+ if (!Number.isInteger(value))
32
+ return null;
33
+ return Math.max(minimum, Math.min(Number(value), maximum));
34
+ }
35
+ function parseProfile(file, expectedName) {
36
+ try {
37
+ const stat = fs.lstatSync(file);
38
+ if (!stat.isFile() || stat.isSymbolicLink())
39
+ return { ok: false, error: 'profile must be a regular file' };
40
+ if (stat.size > MAX_PROFILE_BYTES)
41
+ return { ok: false, error: `profile exceeds ${MAX_PROFILE_BYTES / 1024} KB` };
42
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
43
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
44
+ return { ok: false, error: 'profile must be a JSON object' };
45
+ }
46
+ const value = raw;
47
+ if (value.schemaVersion !== 1)
48
+ return { ok: false, error: 'schemaVersion must be 1' };
49
+ if (value.name !== expectedName || !PROFILE_ID.test(expectedName)) {
50
+ return { ok: false, error: 'profile name must match its lowercase file name' };
51
+ }
52
+ const description = cleanLine(value.description, 400);
53
+ if (!description)
54
+ return { ok: false, error: 'description is required' };
55
+ const instructions = cleanInstructions(value.instructions);
56
+ if (!instructions)
57
+ return { ok: false, error: 'instructions are required' };
58
+ if (Buffer.byteLength(instructions, 'utf8') > MAX_INSTRUCTIONS) {
59
+ return { ok: false, error: `instructions exceed ${MAX_INSTRUCTIONS / 1024} KB` };
60
+ }
61
+ if (!Array.isArray(value.toolScope) || value.toolScope.length === 0 || value.toolScope.length > MAX_TOOLS) {
62
+ return { ok: false, error: `toolScope must contain 1-${MAX_TOOLS} tool names` };
63
+ }
64
+ const toolScope = [];
65
+ for (const rawTool of value.toolScope) {
66
+ if (typeof rawTool !== 'string' || !TOOL_NAME.test(rawTool)) {
67
+ return { ok: false, error: `invalid tool name ${JSON.stringify(rawTool)}` };
68
+ }
69
+ const normalized = rawTool.replace(/\./g, '_');
70
+ if (!toolScope.includes(normalized))
71
+ toolScope.push(normalized);
72
+ }
73
+ const maxSteps = boundedInteger(value.maxSteps, 25, 1, 100);
74
+ if (maxSteps === null)
75
+ return { ok: false, error: 'maxSteps must be an integer' };
76
+ const maxTokens = boundedInteger(value.maxTokens, 50_000, 1_000, 200_000);
77
+ if (maxTokens === null)
78
+ return { ok: false, error: 'maxTokens must be an integer' };
79
+ return {
80
+ ok: true,
81
+ profile: {
82
+ schemaVersion: 1,
83
+ name: expectedName,
84
+ description,
85
+ instructions,
86
+ toolScope,
87
+ maxSteps,
88
+ maxTokens,
89
+ path: file
90
+ }
91
+ };
92
+ }
93
+ catch (error) {
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ return { ok: false, error: message };
96
+ }
97
+ }
98
+ export function loadAgentProfile(name) {
99
+ const id = String(name ?? '');
100
+ if (!PROFILE_ID.test(id))
101
+ return { ok: false, error: 'invalid profile name' };
102
+ return parseProfile(path.join(agentProfilesRoot(), `${id}.json`), id);
103
+ }
104
+ export function listAgentProfiles() {
105
+ const profiles = [];
106
+ const problems = [];
107
+ const root = agentProfilesRoot();
108
+ try {
109
+ if (!fs.existsSync(root))
110
+ return { profiles, problems };
111
+ const entries = fs.readdirSync(root, { withFileTypes: true })
112
+ .filter((entry) => entry.name.endsWith('.json') && !entry.name.startsWith('.'))
113
+ .slice(0, MAX_PROFILES + 1);
114
+ if (entries.length > MAX_PROFILES)
115
+ problems.push(`only the first ${MAX_PROFILES} profiles were inspected`);
116
+ for (const entry of entries.slice(0, MAX_PROFILES)) {
117
+ const name = entry.name.slice(0, -5);
118
+ if (!entry.isFile() || !PROFILE_ID.test(name)) {
119
+ problems.push(`${entry.name}: invalid profile file`);
120
+ continue;
121
+ }
122
+ const loaded = parseProfile(path.join(root, entry.name), name);
123
+ if (loaded.ok)
124
+ profiles.push(loaded.profile);
125
+ else
126
+ problems.push(`${entry.name}: ${loaded.error}`);
127
+ }
128
+ }
129
+ catch (error) {
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ problems.push(`could not read profiles: ${message}`);
132
+ }
133
+ return { profiles, problems };
134
+ }
135
+ export { MAX_PROFILES, MAX_PROFILE_BYTES, MAX_INSTRUCTIONS, MAX_TOOLS };
@@ -0,0 +1,143 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { causyneHome } from './utils/paths.js';
4
+
5
+ const PROFILE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
6
+ const TOOL_NAME = /^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/;
7
+ const MAX_PROFILES = 64;
8
+ const MAX_PROFILE_BYTES = 24 * 1024;
9
+ const MAX_INSTRUCTIONS = 16 * 1024;
10
+ const MAX_TOOLS = 64;
11
+
12
+ export interface AgentProfile {
13
+ schemaVersion: 1;
14
+ name: string;
15
+ description: string;
16
+ instructions: string;
17
+ toolScope: string[];
18
+ maxSteps: number;
19
+ maxTokens: number;
20
+ path: string;
21
+ }
22
+
23
+ export type AgentProfileResult =
24
+ | { ok: true; profile: AgentProfile }
25
+ | { ok: false; error: string };
26
+
27
+ export function agentProfilesRoot(): string {
28
+ return path.join(causyneHome(), 'agents');
29
+ }
30
+
31
+ function cleanLine(value: unknown, maximum: number): string {
32
+ return String(value ?? '')
33
+ // eslint-disable-next-line no-control-regex
34
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
35
+ .replace(/\s+/g, ' ')
36
+ .trim()
37
+ .slice(0, maximum);
38
+ }
39
+
40
+ function cleanInstructions(value: unknown): string {
41
+ return String(value ?? '')
42
+ // Preserve newlines and tabs while excluding prompt-control bytes.
43
+ // eslint-disable-next-line no-control-regex
44
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
45
+ .trim();
46
+ }
47
+
48
+ function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number): number | null {
49
+ if (value === undefined) return fallback;
50
+ if (!Number.isInteger(value)) return null;
51
+ return Math.max(minimum, Math.min(Number(value), maximum));
52
+ }
53
+
54
+ function parseProfile(file: string, expectedName: string): AgentProfileResult {
55
+ try {
56
+ const stat = fs.lstatSync(file);
57
+ if (!stat.isFile() || stat.isSymbolicLink()) return { ok: false, error: 'profile must be a regular file' };
58
+ if (stat.size > MAX_PROFILE_BYTES) return { ok: false, error: `profile exceeds ${MAX_PROFILE_BYTES / 1024} KB` };
59
+ const raw: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
60
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
61
+ return { ok: false, error: 'profile must be a JSON object' };
62
+ }
63
+ const value = raw as Record<string, unknown>;
64
+ if (value.schemaVersion !== 1) return { ok: false, error: 'schemaVersion must be 1' };
65
+ if (value.name !== expectedName || !PROFILE_ID.test(expectedName)) {
66
+ return { ok: false, error: 'profile name must match its lowercase file name' };
67
+ }
68
+ const description = cleanLine(value.description, 400);
69
+ if (!description) return { ok: false, error: 'description is required' };
70
+ const instructions = cleanInstructions(value.instructions);
71
+ if (!instructions) return { ok: false, error: 'instructions are required' };
72
+ if (Buffer.byteLength(instructions, 'utf8') > MAX_INSTRUCTIONS) {
73
+ return { ok: false, error: `instructions exceed ${MAX_INSTRUCTIONS / 1024} KB` };
74
+ }
75
+ if (!Array.isArray(value.toolScope) || value.toolScope.length === 0 || value.toolScope.length > MAX_TOOLS) {
76
+ return { ok: false, error: `toolScope must contain 1-${MAX_TOOLS} tool names` };
77
+ }
78
+ const toolScope: string[] = [];
79
+ for (const rawTool of value.toolScope) {
80
+ if (typeof rawTool !== 'string' || !TOOL_NAME.test(rawTool)) {
81
+ return { ok: false, error: `invalid tool name ${JSON.stringify(rawTool)}` };
82
+ }
83
+ const normalized = rawTool.replace(/\./g, '_');
84
+ if (!toolScope.includes(normalized)) toolScope.push(normalized);
85
+ }
86
+ const maxSteps = boundedInteger(value.maxSteps, 25, 1, 100);
87
+ if (maxSteps === null) return { ok: false, error: 'maxSteps must be an integer' };
88
+ const maxTokens = boundedInteger(value.maxTokens, 50_000, 1_000, 200_000);
89
+ if (maxTokens === null) return { ok: false, error: 'maxTokens must be an integer' };
90
+
91
+ return {
92
+ ok: true,
93
+ profile: {
94
+ schemaVersion: 1,
95
+ name: expectedName,
96
+ description,
97
+ instructions,
98
+ toolScope,
99
+ maxSteps,
100
+ maxTokens,
101
+ path: file
102
+ }
103
+ };
104
+ } catch (error) {
105
+ const message = error instanceof Error ? error.message : String(error);
106
+ return { ok: false, error: message };
107
+ }
108
+ }
109
+
110
+ export function loadAgentProfile(name: unknown): AgentProfileResult {
111
+ const id = String(name ?? '');
112
+ if (!PROFILE_ID.test(id)) return { ok: false, error: 'invalid profile name' };
113
+ return parseProfile(path.join(agentProfilesRoot(), `${id}.json`), id);
114
+ }
115
+
116
+ export function listAgentProfiles(): { profiles: AgentProfile[]; problems: string[] } {
117
+ const profiles: AgentProfile[] = [];
118
+ const problems: string[] = [];
119
+ const root = agentProfilesRoot();
120
+ try {
121
+ if (!fs.existsSync(root)) return { profiles, problems };
122
+ const entries = fs.readdirSync(root, { withFileTypes: true })
123
+ .filter((entry) => entry.name.endsWith('.json') && !entry.name.startsWith('.'))
124
+ .slice(0, MAX_PROFILES + 1);
125
+ if (entries.length > MAX_PROFILES) problems.push(`only the first ${MAX_PROFILES} profiles were inspected`);
126
+ for (const entry of entries.slice(0, MAX_PROFILES)) {
127
+ const name = entry.name.slice(0, -5);
128
+ if (!entry.isFile() || !PROFILE_ID.test(name)) {
129
+ problems.push(`${entry.name}: invalid profile file`);
130
+ continue;
131
+ }
132
+ const loaded = parseProfile(path.join(root, entry.name), name);
133
+ if (loaded.ok) profiles.push(loaded.profile);
134
+ else problems.push(`${entry.name}: ${loaded.error}`);
135
+ }
136
+ } catch (error) {
137
+ const message = error instanceof Error ? error.message : String(error);
138
+ problems.push(`could not read profiles: ${message}`);
139
+ }
140
+ return { profiles, problems };
141
+ }
142
+
143
+ export { MAX_PROFILES, MAX_PROFILE_BYTES, MAX_INSTRUCTIONS, MAX_TOOLS };