zora-agent 0.10.0 → 0.10.2
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/README.md +171 -65
- package/dist/cli/daemon.js +1 -0
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/index.js +27 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/secret-commands.d.ts +16 -0
- package/dist/cli/secret-commands.d.ts.map +1 -0
- package/dist/cli/secret-commands.js +87 -0
- package/dist/cli/secret-commands.js.map +1 -0
- package/dist/cli/skill-commands.d.ts.map +1 -1
- package/dist/cli/skill-commands.js +75 -0
- package/dist/cli/skill-commands.js.map +1 -1
- package/dist/cli/subagent-commands.d.ts +13 -0
- package/dist/cli/subagent-commands.d.ts.map +1 -0
- package/dist/cli/subagent-commands.js +80 -0
- package/dist/cli/subagent-commands.js.map +1 -0
- package/dist/config/defaults.d.ts +6 -0
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +6 -0
- package/dist/config/defaults.js.map +1 -1
- package/dist/dashboard/cost-tracker.d.ts +40 -0
- package/dist/dashboard/cost-tracker.d.ts.map +1 -0
- package/dist/dashboard/cost-tracker.js +63 -0
- package/dist/dashboard/cost-tracker.js.map +1 -0
- package/dist/dashboard/frontend/dist/assets/{index-DSXaCp9r.js → index-Bi0V-1ti.js} +83 -83
- package/dist/dashboard/frontend/dist/assets/index-SQqtXVeO.css +1 -0
- package/dist/dashboard/frontend/dist/index.html +2 -2
- package/dist/dashboard/server.d.ts +5 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +17 -0
- package/dist/dashboard/server.js.map +1 -1
- package/dist/hooks/built-in/audit-log.d.ts +13 -0
- package/dist/hooks/built-in/audit-log.d.ts.map +1 -0
- package/dist/hooks/built-in/audit-log.js +48 -0
- package/dist/hooks/built-in/audit-log.js.map +1 -0
- package/dist/hooks/built-in/rate-limit.d.ts +18 -0
- package/dist/hooks/built-in/rate-limit.d.ts.map +1 -0
- package/dist/hooks/built-in/rate-limit.js +30 -0
- package/dist/hooks/built-in/rate-limit.js.map +1 -0
- package/dist/hooks/built-in/secret-redact.d.ts +7 -0
- package/dist/hooks/built-in/secret-redact.d.ts.map +1 -0
- package/dist/hooks/built-in/secret-redact.js +33 -0
- package/dist/hooks/built-in/secret-redact.js.map +1 -0
- package/dist/hooks/built-in/shell-safety.d.ts +7 -0
- package/dist/hooks/built-in/shell-safety.d.ts.map +1 -0
- package/dist/hooks/built-in/shell-safety.js +34 -0
- package/dist/hooks/built-in/shell-safety.js.map +1 -0
- package/dist/hooks/index.d.ts +5 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +5 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/tool-hook-runner.d.ts +36 -0
- package/dist/hooks/tool-hook-runner.d.ts.map +1 -0
- package/dist/hooks/tool-hook-runner.js +49 -0
- package/dist/hooks/tool-hook-runner.js.map +1 -0
- package/dist/lib/error-normalizer.d.ts +53 -0
- package/dist/lib/error-normalizer.d.ts.map +1 -0
- package/dist/lib/error-normalizer.js +128 -0
- package/dist/lib/error-normalizer.js.map +1 -0
- package/dist/memory/context-compressor.d.ts +3 -1
- package/dist/memory/context-compressor.d.ts.map +1 -1
- package/dist/memory/context-compressor.js +18 -3
- package/dist/memory/context-compressor.js.map +1 -1
- package/dist/memory/plan-cache.d.ts +27 -0
- package/dist/memory/plan-cache.d.ts.map +1 -0
- package/dist/memory/plan-cache.js +91 -0
- package/dist/memory/plan-cache.js.map +1 -0
- package/dist/orchestrator/code-tool-runner.d.ts +41 -0
- package/dist/orchestrator/code-tool-runner.d.ts.map +1 -0
- package/dist/orchestrator/code-tool-runner.js +375 -0
- package/dist/orchestrator/code-tool-runner.js.map +1 -0
- package/dist/orchestrator/error-pattern-detector.d.ts +54 -0
- package/dist/orchestrator/error-pattern-detector.d.ts.map +1 -0
- package/dist/orchestrator/error-pattern-detector.js +87 -0
- package/dist/orchestrator/error-pattern-detector.js.map +1 -0
- package/dist/orchestrator/execution-planner.d.ts +33 -0
- package/dist/orchestrator/execution-planner.d.ts.map +1 -0
- package/dist/orchestrator/execution-planner.js +67 -0
- package/dist/orchestrator/execution-planner.js.map +1 -0
- package/dist/orchestrator/orchestrator.d.ts +77 -0
- package/dist/orchestrator/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator/orchestrator.js +595 -29
- package/dist/orchestrator/orchestrator.js.map +1 -1
- package/dist/orchestrator/retry-queue.d.ts +7 -1
- package/dist/orchestrator/retry-queue.d.ts.map +1 -1
- package/dist/orchestrator/retry-queue.js +10 -4
- package/dist/orchestrator/retry-queue.js.map +1 -1
- package/dist/orchestrator/step-classifier.d.ts +26 -0
- package/dist/orchestrator/step-classifier.d.ts.map +1 -0
- package/dist/orchestrator/step-classifier.js +77 -0
- package/dist/orchestrator/step-classifier.js.map +1 -0
- package/dist/orchestrator/tlci-dispatcher.d.ts +47 -0
- package/dist/orchestrator/tlci-dispatcher.d.ts.map +1 -0
- package/dist/orchestrator/tlci-dispatcher.js +116 -0
- package/dist/orchestrator/tlci-dispatcher.js.map +1 -0
- package/dist/routines/heartbeat.d.ts.map +1 -1
- package/dist/routines/heartbeat.js +3 -1
- package/dist/routines/heartbeat.js.map +1 -1
- package/dist/routines/routine-manager.d.ts.map +1 -1
- package/dist/routines/routine-manager.js +3 -1
- package/dist/routines/routine-manager.js.map +1 -1
- package/dist/security/intent-capsule.d.ts +19 -0
- package/dist/security/intent-capsule.d.ts.map +1 -1
- package/dist/security/intent-capsule.js +84 -1
- package/dist/security/intent-capsule.js.map +1 -1
- package/dist/security/policy-engine.d.ts +1 -0
- package/dist/security/policy-engine.d.ts.map +1 -1
- package/dist/security/policy-engine.js +22 -1
- package/dist/security/policy-engine.js.map +1 -1
- package/dist/services/negative-cache.d.ts +67 -0
- package/dist/services/negative-cache.d.ts.map +1 -0
- package/dist/services/negative-cache.js +164 -0
- package/dist/services/negative-cache.js.map +1 -0
- package/dist/skills/skill-auditor.d.ts +36 -0
- package/dist/skills/skill-auditor.d.ts.map +1 -0
- package/dist/skills/skill-auditor.js +89 -0
- package/dist/skills/skill-auditor.js.map +1 -0
- package/dist/skills/skill-installer.d.ts +41 -0
- package/dist/skills/skill-installer.d.ts.map +1 -0
- package/dist/skills/skill-installer.js +141 -0
- package/dist/skills/skill-installer.js.map +1 -0
- package/dist/skills/skill-scanner.d.ts +30 -0
- package/dist/skills/skill-scanner.d.ts.map +1 -0
- package/dist/skills/skill-scanner.js +249 -0
- package/dist/skills/skill-scanner.js.map +1 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/planning-tool.d.ts +95 -0
- package/dist/tools/planning-tool.d.ts.map +1 -0
- package/dist/tools/planning-tool.js +117 -0
- package/dist/tools/planning-tool.js.map +1 -0
- package/dist/tools/skill-tool.d.ts +94 -0
- package/dist/tools/skill-tool.d.ts.map +1 -0
- package/dist/tools/skill-tool.js +202 -0
- package/dist/tools/skill-tool.js.map +1 -0
- package/dist/tools/subagent-tool.d.ts +11 -0
- package/dist/tools/subagent-tool.d.ts.map +1 -0
- package/dist/tools/subagent-tool.js +87 -0
- package/dist/tools/subagent-tool.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/args.d.ts +5 -0
- package/dist/utils/args.d.ts.map +1 -0
- package/dist/utils/args.js +20 -0
- package/dist/utils/args.js.map +1 -0
- package/package.json +4 -1
- package/dist/dashboard/frontend/dist/assets/index-2FaDr6iS.js +0 -277
- package/dist/dashboard/frontend/dist/assets/index-B9-KXW14.css +0 -1
- package/dist/dashboard/frontend/dist/assets/index-BMxbyTer.css +0 -1
- package/dist/dashboard/frontend/dist/assets/index-BXUfB9iR.js +0 -253
- package/dist/dashboard/frontend/dist/assets/index-BcOGj1EF.css +0 -1
- package/dist/dashboard/frontend/dist/assets/index-BtiFO9YN.js +0 -261
- package/dist/dashboard/frontend/dist/assets/index-CQmpMTLW.js +0 -253
- package/dist/dashboard/frontend/dist/assets/index-Cfjy5acU.css +0 -1
- package/dist/dashboard/frontend/dist/assets/index-D41hcjgc.js +0 -253
- package/dist/dashboard/frontend/dist/assets/index-D83BawFd.css +0 -1
- package/dist/dashboard/frontend/dist/assets/index-DAODjoxu.css +0 -1
- package/dist/dashboard/frontend/dist/assets/index-DB-Eu5oV.js +0 -253
- package/dist/dashboard/frontend/dist/assets/index-W0VVEDu6.js +0 -253
- package/dist/dashboard/frontend/dist/assets/index-aK9PWl6w.js +0 -253
- package/dist/dashboard/frontend/vite.config.d.ts +0 -3
- package/dist/dashboard/frontend/vite.config.d.ts.map +0 -1
- package/dist/dashboard/frontend/vite.config.js +0 -11
- package/dist/dashboard/frontend/vite.config.js.map +0 -1
|
@@ -13,7 +13,15 @@ import crypto from 'node:crypto';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import os from 'node:os';
|
|
15
15
|
import fs from 'node:fs';
|
|
16
|
+
import { ErrorNormalizer } from '../lib/error-normalizer.js';
|
|
17
|
+
import { NegativeCache } from '../services/negative-cache.js';
|
|
18
|
+
import { ErrorPatternDetector } from './error-pattern-detector.js';
|
|
16
19
|
import { HookRunner } from '../hooks/hook-runner.js';
|
|
20
|
+
import { ToolHookRunner } from '../hooks/tool-hook-runner.js';
|
|
21
|
+
import { ShellSafetyHook } from '../hooks/built-in/shell-safety.js';
|
|
22
|
+
import { AuditLogHook } from '../hooks/built-in/audit-log.js';
|
|
23
|
+
import { RateLimitHook } from '../hooks/built-in/rate-limit.js';
|
|
24
|
+
import { SecretRedactHook } from '../hooks/built-in/secret-redact.js';
|
|
17
25
|
import { Router } from './router.js';
|
|
18
26
|
import { FailoverController } from './failover-controller.js';
|
|
19
27
|
import { RetryQueue } from './retry-queue.js';
|
|
@@ -24,9 +32,12 @@ import { SteeringManager } from '../steering/steering-manager.js';
|
|
|
24
32
|
import { MemoryManager } from '../memory/memory-manager.js';
|
|
25
33
|
import { ExtractionPipeline } from '../memory/extraction-pipeline.js';
|
|
26
34
|
import { createMemoryTools } from '../tools/memory-tools.js';
|
|
35
|
+
import { createSkillTools } from '../tools/skill-tool.js';
|
|
36
|
+
import { createSubagentTools } from '../tools/subagent-tool.js';
|
|
27
37
|
import { ValidationPipeline } from '../memory/validation-pipeline.js';
|
|
28
38
|
import { ContextCompressor } from '../memory/context-compressor.js';
|
|
29
39
|
import { ObservationStore } from '../memory/observation-store.js';
|
|
40
|
+
import { ReflectorWorker } from '../memory/reflector-worker.js';
|
|
30
41
|
import { HeartbeatSystem } from '../routines/heartbeat.js';
|
|
31
42
|
import { RoutineManager } from '../routines/routine-manager.js';
|
|
32
43
|
import { NotificationTools } from '../tools/notifications.js';
|
|
@@ -34,7 +45,15 @@ import { PolicyEngine } from '../security/policy-engine.js';
|
|
|
34
45
|
import { IntentCapsuleManager } from '../security/intent-capsule.js';
|
|
35
46
|
import { LeakDetector } from '../security/leak-detector.js';
|
|
36
47
|
import { sanitizeInput } from '../security/prompt-defense.js';
|
|
48
|
+
import { createCapabilityToken, enforceCapability } from '../security/capability-tokens.js';
|
|
49
|
+
import { IntegrityGuardian } from '../security/integrity-guardian.js';
|
|
50
|
+
import { SecretsManager } from '../security/secrets-manager.js';
|
|
37
51
|
import { createLogger } from '../utils/logger.js';
|
|
52
|
+
import { TLCIDispatcher } from './tlci-dispatcher.js';
|
|
53
|
+
import { PlanCache } from '../memory/plan-cache.js';
|
|
54
|
+
import { runCodeToolStep } from './code-tool-runner.js';
|
|
55
|
+
import { CostTracker } from '../dashboard/cost-tracker.js';
|
|
56
|
+
import { createPlanWorkflowTool } from '../tools/planning-tool.js';
|
|
38
57
|
const log = createLogger('orchestrator');
|
|
39
58
|
export class Orchestrator {
|
|
40
59
|
_config;
|
|
@@ -54,6 +73,10 @@ export class Orchestrator {
|
|
|
54
73
|
// Security
|
|
55
74
|
_intentCapsuleManager;
|
|
56
75
|
_leakDetector;
|
|
76
|
+
_integrityGuardian;
|
|
77
|
+
_secretsManager;
|
|
78
|
+
// Per-job capability tokens (keyed by jobId) for worker isolation enforcement
|
|
79
|
+
_activeTokens = new Map();
|
|
57
80
|
// Background systems
|
|
58
81
|
_heartbeatSystem = null;
|
|
59
82
|
_routineManager = null;
|
|
@@ -61,15 +84,27 @@ export class Orchestrator {
|
|
|
61
84
|
_validationPipeline;
|
|
62
85
|
// ORCH-12: Lifecycle hooks
|
|
63
86
|
_hookRunner = new HookRunner();
|
|
87
|
+
// Tool-level lifecycle hooks
|
|
88
|
+
_toolHookRunner = new ToolHookRunner();
|
|
89
|
+
// ERR-07: Error normalizer for safe error replay
|
|
90
|
+
_errorNormalizer = new ErrorNormalizer();
|
|
91
|
+
// ERR-12 Lite: Global negative cache for cross-session learning
|
|
92
|
+
_negativeCache;
|
|
64
93
|
// ORCH-14: Context transform callback
|
|
65
94
|
_transformContext = defaultTransformContext;
|
|
66
95
|
// Context compression
|
|
67
96
|
_observationStore;
|
|
97
|
+
_reflectorWorker;
|
|
68
98
|
// Background intervals
|
|
69
99
|
_authCheckTimeout = null;
|
|
70
100
|
_retryPollTimeout = null;
|
|
71
101
|
_consolidationTimeout = null;
|
|
72
102
|
_booted = false;
|
|
103
|
+
// TLCI: lazy-initialized dispatcher and plan cache (additive — does not affect submitTask)
|
|
104
|
+
_planCache;
|
|
105
|
+
_tlciDispatcher;
|
|
106
|
+
_tlciCostTracker;
|
|
107
|
+
_tlciInitP; // Promise guard prevents double-init on concurrent calls
|
|
73
108
|
constructor(options) {
|
|
74
109
|
this._config = options.config;
|
|
75
110
|
this._policy = options.policy;
|
|
@@ -105,6 +140,44 @@ export class Orchestrator {
|
|
|
105
140
|
this._policyEngine.setIntentCapsuleManager(this._intentCapsuleManager);
|
|
106
141
|
// SEC-03: Wire LeakDetector for scanning tool outputs
|
|
107
142
|
this._leakDetector = new LeakDetector();
|
|
143
|
+
// SEC-11: IntegrityGuardian — baseline + tamper detection for critical config files
|
|
144
|
+
this._integrityGuardian = new IntegrityGuardian(this._baseDir);
|
|
145
|
+
const baselinesPath = path.join(this._baseDir, 'state', 'integrity-baselines.json');
|
|
146
|
+
let baselinesExist = false;
|
|
147
|
+
try {
|
|
148
|
+
await fs.promises.access(baselinesPath);
|
|
149
|
+
baselinesExist = true;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// first boot — baselines don't exist yet
|
|
153
|
+
}
|
|
154
|
+
if (!baselinesExist) {
|
|
155
|
+
await this._integrityGuardian.saveBaseline();
|
|
156
|
+
log.info('Integrity baselines established on first boot');
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const integrityResult = await this._integrityGuardian.checkIntegrity();
|
|
160
|
+
if (!integrityResult.valid) {
|
|
161
|
+
for (const mismatch of integrityResult.mismatches) {
|
|
162
|
+
log.warn({ file: mismatch.file, expected: mismatch.expected.slice(0, 8), actual: mismatch.actual.slice(0, 8) }, 'Integrity mismatch — possible tampering detected');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
log.info('Config integrity: clean');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// SEC-12: SecretsManager — AES-256-GCM encrypted secrets at rest
|
|
170
|
+
const masterPassword = process.env['ZORA_MASTER_PASSWORD'];
|
|
171
|
+
if (masterPassword) {
|
|
172
|
+
this._secretsManager = new SecretsManager(this._baseDir, masterPassword);
|
|
173
|
+
await this._secretsManager.init();
|
|
174
|
+
log.info('SecretsManager initialized');
|
|
175
|
+
// TODO: wire secret values into SecretRedactHook.addPattern() once that
|
|
176
|
+
// method is added to the hook's interface (SecretRedactHook has no addPattern yet)
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
log.warn('ZORA_MASTER_PASSWORD not set — encrypted secrets storage unavailable. Set this env var to enable.');
|
|
180
|
+
}
|
|
108
181
|
this._sessionManager = new SessionManager(this._baseDir);
|
|
109
182
|
this._steeringManager = new SteeringManager(this._baseDir);
|
|
110
183
|
await this._steeringManager.init();
|
|
@@ -114,6 +187,12 @@ export class Orchestrator {
|
|
|
114
187
|
// Initialize observation store for context compression
|
|
115
188
|
this._observationStore = new ObservationStore(path.join(this._baseDir, 'memory', 'observations'));
|
|
116
189
|
await this._observationStore.init();
|
|
190
|
+
// MEM-20: Initialize ReflectorWorker if compression is enabled
|
|
191
|
+
if (this._config.memory?.compression?.enabled) {
|
|
192
|
+
const compressFn = this._buildCompressFn();
|
|
193
|
+
this._reflectorWorker = new ReflectorWorker(compressFn, this._memoryManager);
|
|
194
|
+
log.info('ReflectorWorker initialized');
|
|
195
|
+
}
|
|
117
196
|
// R2: Wire Router
|
|
118
197
|
this._router = new Router({
|
|
119
198
|
providers: this._providers,
|
|
@@ -125,6 +204,9 @@ export class Orchestrator {
|
|
|
125
204
|
// R5: Initialize RetryQueue
|
|
126
205
|
this._retryQueue = new RetryQueue(this._baseDir);
|
|
127
206
|
await this._retryQueue.init();
|
|
207
|
+
// ERR-12 Lite: Initialize global negative cache
|
|
208
|
+
this._negativeCache = new NegativeCache(this._baseDir);
|
|
209
|
+
await this._negativeCache.init();
|
|
128
210
|
// R4: Schedule AuthMonitor
|
|
129
211
|
this._authMonitor = new AuthMonitor({
|
|
130
212
|
providers: this._providers,
|
|
@@ -145,18 +227,29 @@ export class Orchestrator {
|
|
|
145
227
|
}, 5 * 60 * 1000);
|
|
146
228
|
};
|
|
147
229
|
scheduleAuthCheck();
|
|
148
|
-
// R5: Poll RetryQueue (every 30 seconds) —
|
|
230
|
+
// R5 / ERR-08: Poll RetryQueue (every 30 seconds) — use _resumeTask to preserve full
|
|
231
|
+
// TaskContext (state continuity) instead of re-submitting just the original prompt.
|
|
149
232
|
const scheduleRetryPoll = () => {
|
|
150
233
|
this._retryPollTimeout = setTimeout(async () => {
|
|
151
234
|
try {
|
|
152
|
-
const
|
|
153
|
-
for (const
|
|
235
|
+
const readyEntries = this._retryQueue.getReadyEntries();
|
|
236
|
+
for (const entry of readyEntries) {
|
|
154
237
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
238
|
+
// ERR-08: Increment budgetConsumed before re-executing to track retry depth
|
|
239
|
+
if (entry.task.errorBudget) {
|
|
240
|
+
entry.task.errorBudget.budgetConsumed += 1;
|
|
241
|
+
// Skip tasks with exhausted budget
|
|
242
|
+
if (entry.task.errorBudget.budgetConsumed >= entry.task.errorBudget.maxBudget) {
|
|
243
|
+
log.warn({ jobId: entry.task.jobId }, 'Retry skipped: error budget exhausted');
|
|
244
|
+
await this._retryQueue.remove(entry.task.jobId);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
await this._resumeTask(entry.task);
|
|
249
|
+
await this._retryQueue.remove(entry.task.jobId);
|
|
157
250
|
}
|
|
158
251
|
catch (err) {
|
|
159
|
-
log.error({ jobId: task.jobId, err }, 'Retry failed');
|
|
252
|
+
log.error({ jobId: entry.task.jobId, err }, 'Retry failed');
|
|
160
253
|
// Leave task in queue for next poll cycle
|
|
161
254
|
}
|
|
162
255
|
}
|
|
@@ -192,7 +285,12 @@ export class Orchestrator {
|
|
|
192
285
|
const scheduleConsolidation = () => {
|
|
193
286
|
this._consolidationTimeout = setTimeout(async () => {
|
|
194
287
|
try {
|
|
195
|
-
const
|
|
288
|
+
const reflectFn = this._reflectorWorker
|
|
289
|
+
? async (content) => {
|
|
290
|
+
await this._reflectorWorker.reflect(content, `consolidation_${Date.now()}`);
|
|
291
|
+
}
|
|
292
|
+
: undefined;
|
|
293
|
+
const count = await this._memoryManager.consolidateDailyNotes(7, reflectFn);
|
|
196
294
|
if (count > 0) {
|
|
197
295
|
log.info({ consolidated: count }, 'Daily notes consolidated');
|
|
198
296
|
}
|
|
@@ -206,13 +304,26 @@ export class Orchestrator {
|
|
|
206
304
|
// Run first check shortly after boot (30 seconds), then daily
|
|
207
305
|
this._consolidationTimeout = setTimeout(async () => {
|
|
208
306
|
try {
|
|
209
|
-
|
|
307
|
+
const reflectFn = this._reflectorWorker
|
|
308
|
+
? async (content) => {
|
|
309
|
+
await this._reflectorWorker.reflect(content, `consolidation_${Date.now()}`);
|
|
310
|
+
}
|
|
311
|
+
: undefined;
|
|
312
|
+
await this._memoryManager.consolidateDailyNotes(7, reflectFn);
|
|
210
313
|
}
|
|
211
314
|
catch (err) {
|
|
212
315
|
log.warn({ err }, 'Initial daily note consolidation failed');
|
|
213
316
|
}
|
|
214
317
|
scheduleConsolidation();
|
|
215
318
|
}, 30 * 1000);
|
|
319
|
+
// Register default tool-level hooks
|
|
320
|
+
this._toolHookRunner.register(ShellSafetyHook);
|
|
321
|
+
this._toolHookRunner.register(new AuditLogHook());
|
|
322
|
+
this._toolHookRunner.register(new RateLimitHook([
|
|
323
|
+
{ tool: 'bash', maxCalls: 60, windowMs: 60_000 },
|
|
324
|
+
{ tool: 'http_request', maxCalls: 100, windowMs: 60_000 },
|
|
325
|
+
]));
|
|
326
|
+
this._toolHookRunner.register(SecretRedactHook);
|
|
216
327
|
this._booted = true;
|
|
217
328
|
}
|
|
218
329
|
/**
|
|
@@ -262,6 +373,9 @@ export class Orchestrator {
|
|
|
262
373
|
*/
|
|
263
374
|
async submitTask(options) {
|
|
264
375
|
const jobId = options.jobId ?? `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
376
|
+
// SEC-10: Create a scoped capability token for this job
|
|
377
|
+
const capToken = createCapabilityToken(jobId, this._policy);
|
|
378
|
+
this._activeTokens.set(jobId, capToken);
|
|
265
379
|
// Reset per-task state: ValidationPipeline rate limit is per-session, not per-orchestrator-lifetime.
|
|
266
380
|
// Without this, after MAX_SAVES_PER_SESSION saves across all tasks, memory_save permanently blocks.
|
|
267
381
|
this._validationPipeline.resetSession();
|
|
@@ -288,17 +402,12 @@ export class Orchestrator {
|
|
|
288
402
|
// Create per-task context compressor if compression is enabled
|
|
289
403
|
let compressor = null;
|
|
290
404
|
if (this._config.memory?.compression?.enabled) {
|
|
291
|
-
const compressFn =
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
model: this._config.memory.compression.model,
|
|
298
|
-
});
|
|
299
|
-
return compressLoop.run(prompt);
|
|
300
|
-
};
|
|
301
|
-
compressor = new ContextCompressor(this._config.memory.compression, this._observationStore, compressFn, jobId);
|
|
405
|
+
const compressFn = this._buildCompressFn();
|
|
406
|
+
compressor = new ContextCompressor(this._config.memory.compression, this._observationStore, compressFn, jobId, this._reflectorWorker
|
|
407
|
+
? async (obs, sid) => {
|
|
408
|
+
await this._reflectorWorker.reflectAndPersist(obs, sid, this._observationStore);
|
|
409
|
+
}
|
|
410
|
+
: undefined);
|
|
302
411
|
await compressor.loadExisting();
|
|
303
412
|
}
|
|
304
413
|
// Build cross-session context from observations
|
|
@@ -326,12 +435,29 @@ export class Orchestrator {
|
|
|
326
435
|
}
|
|
327
436
|
// ASI01: Create signed intent capsule for goal drift detection
|
|
328
437
|
if (this._intentCapsuleManager) {
|
|
329
|
-
this._intentCapsuleManager.
|
|
438
|
+
const inferredCategories = this._intentCapsuleManager.inferCategories(sanitizedPrompt);
|
|
439
|
+
this._intentCapsuleManager.createCapsule(sanitizedPrompt, {
|
|
440
|
+
// Only set allowedActionCategories when constraints were actually inferred.
|
|
441
|
+
// Passing an empty array would block all actions; undefined means no restriction.
|
|
442
|
+
allowedActionCategories: inferredCategories.length > 0 ? inferredCategories : undefined,
|
|
443
|
+
});
|
|
444
|
+
if (inferredCategories.length > 0) {
|
|
445
|
+
log.info({ jobId, inferredCategories }, 'Intent capsule created with inferred action categories');
|
|
446
|
+
}
|
|
330
447
|
}
|
|
331
448
|
// Classify task for routing
|
|
332
449
|
const classification = this._router.classifyTask(sanitizedPrompt);
|
|
333
450
|
// Build custom tools (permissions + memory tools + recall_context)
|
|
334
451
|
const customTools = this._createCustomTools();
|
|
452
|
+
// ERR-09: Build initial error budget — maxBudget from failover config, maxTurns from options
|
|
453
|
+
const maxRetries = this._config.failover.max_retries ?? 3;
|
|
454
|
+
const maxTurns = options.maxTurns ?? 0;
|
|
455
|
+
const errorBudget = {
|
|
456
|
+
maxBudget: maxRetries,
|
|
457
|
+
budgetConsumed: 0,
|
|
458
|
+
maxTurns,
|
|
459
|
+
turnsConsumed: 0,
|
|
460
|
+
};
|
|
335
461
|
// Build task context
|
|
336
462
|
const taskContext = {
|
|
337
463
|
jobId,
|
|
@@ -345,8 +471,9 @@ export class Orchestrator {
|
|
|
345
471
|
modelPreference: options.model,
|
|
346
472
|
maxCostTier: options.maxCostTier,
|
|
347
473
|
maxTurns: options.maxTurns,
|
|
474
|
+
errorBudget,
|
|
348
475
|
customTools,
|
|
349
|
-
canUseTool: this.
|
|
476
|
+
canUseTool: this._buildTokenAwareCanUseTool(jobId),
|
|
350
477
|
};
|
|
351
478
|
// ORCH-12: Run onTaskStart hooks (can modify context before routing)
|
|
352
479
|
const hookedContext = await this._hookRunner.runOnTaskStart(taskContext);
|
|
@@ -356,10 +483,17 @@ export class Orchestrator {
|
|
|
356
483
|
selectedProvider = await this._router.selectProvider(hookedContext);
|
|
357
484
|
}
|
|
358
485
|
catch (err) {
|
|
486
|
+
this._activeTokens.delete(jobId);
|
|
359
487
|
throw new Error(`No provider available: ${err instanceof Error ? err.message : String(err)}`);
|
|
360
488
|
}
|
|
361
489
|
// Execute with the selected provider (injectionDepth=0 for initial call)
|
|
362
|
-
|
|
490
|
+
// SEC-10: Clean up capability token after task completes (success or failure)
|
|
491
|
+
try {
|
|
492
|
+
return await this._executeWithProvider(selectedProvider, hookedContext, options.onEvent, 0, 0, compressor);
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
this._activeTokens.delete(jobId);
|
|
496
|
+
}
|
|
363
497
|
}
|
|
364
498
|
/** Tracks errors that have already been through the failover path */
|
|
365
499
|
static _failoverErrors = new WeakSet();
|
|
@@ -381,10 +515,37 @@ export class Orchestrator {
|
|
|
381
515
|
* - The _failoverErrors WeakSet prevents double-failover: errors already processed
|
|
382
516
|
* by the failover path are not re-triggered in the outer catch block.
|
|
383
517
|
*/
|
|
384
|
-
async _executeWithProvider(provider, taskContext, onEvent, failoverDepth = 0, injectionDepth = 0, compressor) {
|
|
518
|
+
async _executeWithProvider(provider, taskContext, onEvent, failoverDepth = 0, injectionDepth = 0, compressor, patternDetectorIn) {
|
|
519
|
+
// ERR-09: Check error budget before every provider call
|
|
520
|
+
if (taskContext.errorBudget) {
|
|
521
|
+
const budget = taskContext.errorBudget;
|
|
522
|
+
if (budget.budgetConsumed >= budget.maxBudget) {
|
|
523
|
+
const errEvent = {
|
|
524
|
+
type: 'error',
|
|
525
|
+
timestamp: new Date(),
|
|
526
|
+
source: 'orchestrator',
|
|
527
|
+
content: {
|
|
528
|
+
message: `Error budget exceeded: ${budget.budgetConsumed}/${budget.maxBudget} retries consumed`,
|
|
529
|
+
code: 'error_budget_exceeded',
|
|
530
|
+
subtype: 'budget_consumed',
|
|
531
|
+
},
|
|
532
|
+
};
|
|
533
|
+
if (onEvent)
|
|
534
|
+
onEvent(errEvent);
|
|
535
|
+
throw new Error(`error_budget_exceeded: retry budget exhausted (${budget.budgetConsumed}/${budget.maxBudget})`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
385
538
|
let result = '';
|
|
386
539
|
let eventsSinceLastTick = 0;
|
|
387
540
|
const TICK_INTERVAL = 10; // Check compression thresholds every N events
|
|
541
|
+
// ERR-10: Per-execution pattern detector (in-session circuit breaker)
|
|
542
|
+
const patternDetector = patternDetectorIn ?? new ErrorPatternDetector();
|
|
543
|
+
// ERR-09: Stale-state loop detection — track whether a tool was called this turn
|
|
544
|
+
let toolCalledThisTurn = false;
|
|
545
|
+
let consecutiveNonToolTurns = 0;
|
|
546
|
+
const STALE_LOOP_THRESHOLD = 3;
|
|
547
|
+
// Tool-level hook tracking: map toolCallId → call start timestamp
|
|
548
|
+
const _toolCallStartTimes = new Map();
|
|
388
549
|
// Event batching: buffer session writes, flush every 500ms or on done/error.
|
|
389
550
|
// Wrapped in try/finally to ensure close() runs on ALL exit paths including failover.
|
|
390
551
|
const bufferedWriter = new BufferedSessionWriter(this._sessionManager, taskContext.jobId, 500);
|
|
@@ -392,8 +553,12 @@ export class Orchestrator {
|
|
|
392
553
|
try {
|
|
393
554
|
// Execute via the provider's async generator
|
|
394
555
|
for await (const event of provider.execute(taskContext)) {
|
|
395
|
-
// R8: Persist events via buffered writer (batched disk I/O)
|
|
396
|
-
|
|
556
|
+
// R8: Persist events via buffered writer (batched disk I/O).
|
|
557
|
+
// tool_call events are deferred until after before-hooks run so
|
|
558
|
+
// SecretRedactHook can modify args before they hit the log.
|
|
559
|
+
if (event.type !== 'tool_call') {
|
|
560
|
+
bufferedWriter.append(event);
|
|
561
|
+
}
|
|
397
562
|
// Feed events to context compressor for rolling compression
|
|
398
563
|
if (compressor) {
|
|
399
564
|
compressor.ingest(event);
|
|
@@ -425,7 +590,125 @@ export class Orchestrator {
|
|
|
425
590
|
if (leaks.length > 0) {
|
|
426
591
|
log.warn({ jobId: taskContext.jobId, tool: toolCallContent.tool, leaks: leaks.map(l => ({ pattern: l.pattern, severity: l.severity })) }, 'Potential secret leak detected in tool call arguments');
|
|
427
592
|
}
|
|
593
|
+
// ERR-12 Lite: Check NegativeCache for hot-failing tool signatures
|
|
594
|
+
const args = toolCallContent.arguments ?? {};
|
|
595
|
+
try {
|
|
596
|
+
const cacheResult = await this._negativeCache.check(toolCallContent.tool, args);
|
|
597
|
+
if (cacheResult.isHotFailing && cacheResult.hint) {
|
|
598
|
+
log.warn({ jobId: taskContext.jobId, tool: toolCallContent.tool, failures: cacheResult.failureCount }, 'NegativeCache: hot-failing tool detected — injecting system hint');
|
|
599
|
+
const hintEvent = {
|
|
600
|
+
type: 'steering',
|
|
601
|
+
timestamp: new Date(),
|
|
602
|
+
source: 'negative-cache',
|
|
603
|
+
content: { text: cacheResult.hint, source: 'negative-cache', author: 'system' },
|
|
604
|
+
};
|
|
605
|
+
taskContext.history.push(hintEvent);
|
|
606
|
+
if (onEvent)
|
|
607
|
+
onEvent(hintEvent);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
log.debug({ err }, 'NegativeCache check failed (non-critical)');
|
|
612
|
+
}
|
|
613
|
+
// ERR-09: Stale-state loop — tool call resets the consecutive counter
|
|
614
|
+
toolCalledThisTurn = true;
|
|
615
|
+
consecutiveNonToolTurns = 0;
|
|
616
|
+
// Tool-level before-hooks: record start time and run before-hooks.
|
|
617
|
+
// Logging happens AFTER hooks so SecretRedactHook can redact args
|
|
618
|
+
// before they are written to disk or the session log.
|
|
619
|
+
_toolCallStartTimes.set(toolCallContent.toolCallId, Date.now());
|
|
620
|
+
try {
|
|
621
|
+
const hookBefore = await this._toolHookRunner.runBefore({
|
|
622
|
+
jobId: taskContext.jobId,
|
|
623
|
+
tool: toolCallContent.tool,
|
|
624
|
+
arguments: toolCallContent.arguments ?? {},
|
|
625
|
+
});
|
|
626
|
+
// Log the event now (with potentially-redacted args from hooks)
|
|
627
|
+
const hookedArgs = hookBefore.args;
|
|
628
|
+
const loggedEvent = hookedArgs !== (toolCallContent.arguments ?? {})
|
|
629
|
+
? { ...event, content: { ...toolCallContent, arguments: hookedArgs } }
|
|
630
|
+
: event;
|
|
631
|
+
bufferedWriter.append(loggedEvent);
|
|
632
|
+
log.debug({ jobId: taskContext.jobId, tool: toolCallContent.tool, arguments: hookedArgs }, 'tool call');
|
|
633
|
+
if (!hookBefore.allow) {
|
|
634
|
+
// Inject a synthetic tool_result indicating the tool was blocked
|
|
635
|
+
const blockedEvent = {
|
|
636
|
+
type: 'tool_result',
|
|
637
|
+
timestamp: new Date(),
|
|
638
|
+
source: 'tool-hook-runner',
|
|
639
|
+
content: {
|
|
640
|
+
toolCallId: toolCallContent.toolCallId,
|
|
641
|
+
result: null,
|
|
642
|
+
error: `Tool call blocked by hook: ${toolCallContent.tool}`,
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
bufferedWriter.append(blockedEvent);
|
|
646
|
+
taskContext.history.push(blockedEvent);
|
|
647
|
+
if (onEvent)
|
|
648
|
+
onEvent(blockedEvent);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
// If hooks fail, still log the original event so the call isn't lost
|
|
653
|
+
bufferedWriter.append(event);
|
|
654
|
+
log.error({ err, tool: toolCallContent.tool, jobId: taskContext.jobId }, 'tool-hook runBefore error (non-critical)');
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// ERR-10: Pattern detection on tool results + ERR-12: record failures/successes
|
|
658
|
+
if (event.type === 'tool_result') {
|
|
659
|
+
const toolResultContent = event.content;
|
|
660
|
+
const hasFailed = Boolean(toolResultContent.error);
|
|
661
|
+
// Find the matching tool_call in history to get name + args
|
|
662
|
+
const matchingCall = [...taskContext.history].reverse().find(e => e.type === 'tool_call' &&
|
|
663
|
+
e.content.toolCallId === toolResultContent.toolCallId);
|
|
664
|
+
if (matchingCall) {
|
|
665
|
+
const callContent = matchingCall.content;
|
|
666
|
+
const args = callContent.arguments ?? {};
|
|
667
|
+
// ERR-12: Record failure/success in persistent negative cache
|
|
668
|
+
if (hasFailed) {
|
|
669
|
+
this._negativeCache.recordFailure(callContent.tool, args).catch(err => {
|
|
670
|
+
log.debug({ err }, 'NegativeCache recordFailure failed (non-critical)');
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
this._negativeCache.recordSuccess(callContent.tool, args).catch(err => {
|
|
675
|
+
log.debug({ err }, 'NegativeCache recordSuccess failed (non-critical)');
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
// ERR-10: In-session circuit breaker — detect repeat failures
|
|
679
|
+
const detection = patternDetector.record(callContent.tool, args, !hasFailed);
|
|
680
|
+
if (detection.isRepeating && detection.hint) {
|
|
681
|
+
log.warn({ jobId: taskContext.jobId, tool: detection.toolName }, 'ERR-10: Repeat tool failure detected — injecting hard steering hint');
|
|
682
|
+
const hintEvent = {
|
|
683
|
+
type: 'steering',
|
|
684
|
+
timestamp: new Date(),
|
|
685
|
+
source: 'error-pattern-detector',
|
|
686
|
+
content: { text: detection.hint, source: 'error-pattern-detector', author: 'system' },
|
|
687
|
+
};
|
|
688
|
+
bufferedWriter.append(hintEvent);
|
|
689
|
+
taskContext.history.push(hintEvent);
|
|
690
|
+
if (onEvent)
|
|
691
|
+
onEvent(hintEvent);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// ERR-09: Stale-state — tool_result is a "turn", but only counts if no tool call followed
|
|
695
|
+
// (tracked by text events below)
|
|
696
|
+
// Tool-level after-hooks: run after every tool result
|
|
697
|
+
const _startMs = _toolCallStartTimes.get(toolResultContent.toolCallId);
|
|
698
|
+
_toolCallStartTimes.delete(toolResultContent.toolCallId);
|
|
699
|
+
const _matchingCallForHook = matchingCall;
|
|
700
|
+
if (_matchingCallForHook) {
|
|
701
|
+
const _callContentForHook = _matchingCallForHook.content;
|
|
702
|
+
await this._toolHookRunner.runAfter({
|
|
703
|
+
jobId: taskContext.jobId,
|
|
704
|
+
tool: _callContentForHook.tool,
|
|
705
|
+
arguments: _callContentForHook.arguments ?? {},
|
|
706
|
+
result: toolResultContent.result,
|
|
707
|
+
durationMs: _startMs !== undefined ? Date.now() - _startMs : undefined,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
428
710
|
}
|
|
711
|
+
// (ERR-09: stale-state tracking is done on 'done' events below)
|
|
429
712
|
// R7: Poll SteeringManager with debouncing (max once per 2 seconds)
|
|
430
713
|
if (event.type === 'text' || event.type === 'tool_result') {
|
|
431
714
|
const pendingMessages = await this._steeringManager.cachedGetPendingMessages(taskContext.jobId, 2000);
|
|
@@ -450,13 +733,82 @@ export class Orchestrator {
|
|
|
450
733
|
onEvent(event);
|
|
451
734
|
// Track history for failover handoff
|
|
452
735
|
taskContext.history.push(event);
|
|
453
|
-
// Capture result text
|
|
736
|
+
// Capture result text and enforce turn budget
|
|
454
737
|
if (event.type === 'done') {
|
|
455
738
|
result = event.content.text ?? '';
|
|
739
|
+
// ERR-09: Increment turnsConsumed and check maxTurns limit
|
|
740
|
+
if (taskContext.errorBudget) {
|
|
741
|
+
const budget = taskContext.errorBudget;
|
|
742
|
+
budget.turnsConsumed += 1;
|
|
743
|
+
// Stale-state detection: count consecutive turns with no tool calls
|
|
744
|
+
if (!toolCalledThisTurn) {
|
|
745
|
+
consecutiveNonToolTurns += 1;
|
|
746
|
+
if (consecutiveNonToolTurns >= STALE_LOOP_THRESHOLD) {
|
|
747
|
+
const staleEvent = {
|
|
748
|
+
type: 'error',
|
|
749
|
+
timestamp: new Date(),
|
|
750
|
+
source: 'orchestrator',
|
|
751
|
+
content: {
|
|
752
|
+
message: `Stale state loop: ${consecutiveNonToolTurns} consecutive turns without tool calls`,
|
|
753
|
+
code: 'error_budget_exceeded',
|
|
754
|
+
subtype: 'stale_state_loop',
|
|
755
|
+
},
|
|
756
|
+
};
|
|
757
|
+
bufferedWriter.append(staleEvent);
|
|
758
|
+
if (onEvent)
|
|
759
|
+
onEvent(staleEvent);
|
|
760
|
+
throw new Error(`error_budget_exceeded: stale state loop detected (${consecutiveNonToolTurns} turns without tool calls)`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
consecutiveNonToolTurns = 0;
|
|
765
|
+
}
|
|
766
|
+
// Reset per-turn flag
|
|
767
|
+
toolCalledThisTurn = false;
|
|
768
|
+
// Check hard maxTurns limit
|
|
769
|
+
if (budget.maxTurns > 0 && budget.turnsConsumed >= budget.maxTurns) {
|
|
770
|
+
const turnErrEvent = {
|
|
771
|
+
type: 'error',
|
|
772
|
+
timestamp: new Date(),
|
|
773
|
+
source: 'orchestrator',
|
|
774
|
+
content: {
|
|
775
|
+
message: `Turn limit exceeded: ${budget.turnsConsumed}/${budget.maxTurns} turns consumed`,
|
|
776
|
+
code: 'error_budget_exceeded',
|
|
777
|
+
subtype: 'turn_limit_exceeded',
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
if (onEvent)
|
|
781
|
+
onEvent(turnErrEvent);
|
|
782
|
+
throw new Error(`error_budget_exceeded: turn limit exhausted (${budget.turnsConsumed}/${budget.maxTurns})`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
// Reset per-turn flag even without budget tracking
|
|
787
|
+
toolCalledThisTurn = false;
|
|
788
|
+
}
|
|
456
789
|
}
|
|
457
790
|
// Handle errors — trigger failover (R3)
|
|
458
791
|
if (event.type === 'error') {
|
|
459
792
|
const errorContent = event.content;
|
|
793
|
+
// ERR-07: Normalize error for structured logging (safe message, category)
|
|
794
|
+
const normalized = this._errorNormalizer.normalize(errorContent.message ?? 'Unknown provider error');
|
|
795
|
+
log.warn({ jobId: taskContext.jobId, category: normalized.category, message: normalized.safeMessage }, 'ERR-07: Provider error normalized');
|
|
796
|
+
// ERR-07: Inject failure_report as a tool_result event into history for LLM context
|
|
797
|
+
const failureToolCallId = `err_${Date.now()}`;
|
|
798
|
+
const failureReport = this._errorNormalizer.toFailureReport(failureToolCallId, normalized);
|
|
799
|
+
const failureEvent = {
|
|
800
|
+
type: 'tool_result',
|
|
801
|
+
timestamp: new Date(),
|
|
802
|
+
source: 'orchestrator',
|
|
803
|
+
content: {
|
|
804
|
+
toolCallId: failureToolCallId,
|
|
805
|
+
result: failureReport,
|
|
806
|
+
error: normalized.safeMessage,
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
taskContext.history.push(failureEvent);
|
|
810
|
+
if (onEvent)
|
|
811
|
+
onEvent(failureEvent);
|
|
460
812
|
const error = new Error(errorContent.message ?? 'Unknown provider error');
|
|
461
813
|
// Guard: skip failover if depth exceeded
|
|
462
814
|
if (failoverDepth >= Orchestrator.MAX_FAILOVER_DEPTH) {
|
|
@@ -466,7 +818,8 @@ export class Orchestrator {
|
|
|
466
818
|
const failoverResult = await this._failoverController.handleFailure(taskContext, provider, error);
|
|
467
819
|
if (failoverResult) {
|
|
468
820
|
// Re-execute with the failover provider (increment depth)
|
|
469
|
-
|
|
821
|
+
// Preserve intent capsule across failover and pass same patternDetector
|
|
822
|
+
return this._executeWithFailoverProvider(failoverResult.nextProvider, taskContext, onEvent, failoverDepth + 1, injectionDepth, compressor, patternDetector);
|
|
470
823
|
}
|
|
471
824
|
// R5: Enqueue for retry if no failover available
|
|
472
825
|
try {
|
|
@@ -490,7 +843,8 @@ export class Orchestrator {
|
|
|
490
843
|
if (failoverResult) {
|
|
491
844
|
// Mark the error so downstream doesn't re-trigger failover
|
|
492
845
|
Orchestrator._failoverErrors.add(err);
|
|
493
|
-
|
|
846
|
+
// Preserve intent capsule across failover and pass same patternDetector
|
|
847
|
+
return this._executeWithFailoverProvider(failoverResult.nextProvider, taskContext, onEvent, failoverDepth + 1, injectionDepth, compressor, patternDetector);
|
|
494
848
|
}
|
|
495
849
|
// R5: Enqueue for retry
|
|
496
850
|
try {
|
|
@@ -549,6 +903,133 @@ export class Orchestrator {
|
|
|
549
903
|
}
|
|
550
904
|
return result;
|
|
551
905
|
}
|
|
906
|
+
/**
|
|
907
|
+
* submitWorkflow — TLCI-aware multi-step workflow dispatch.
|
|
908
|
+
*
|
|
909
|
+
* Routes each step to the cheapest capable tier:
|
|
910
|
+
* Tier 1 (code): deterministic code tools — httpFetch, transform, fileOp, etc.
|
|
911
|
+
* Tier 2 (slm): local Ollama model (free cost-tier provider), falls back to frontier
|
|
912
|
+
* Tier 3 (frontier): existing Zora provider stack (Claude/Gemini)
|
|
913
|
+
*
|
|
914
|
+
* Pass structured parameters for code-tool steps via WorkflowStep.context:
|
|
915
|
+
* { id: '1', description: 'fetch user data', context: { url: 'https://api.example.com/users' } }
|
|
916
|
+
*
|
|
917
|
+
* Additive — does not touch submitTask.
|
|
918
|
+
*/
|
|
919
|
+
async submitWorkflow(steps, opts) {
|
|
920
|
+
await this._ensureTLCI();
|
|
921
|
+
return this._tlciDispatcher.dispatch(steps, opts ?? {});
|
|
922
|
+
}
|
|
923
|
+
/** Expose CostTracker for DashboardServer /api/tlci-stats wiring. */
|
|
924
|
+
getTLCICostTracker() {
|
|
925
|
+
return this._tlciCostTracker;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Initialize TLCI subsystem exactly once, even under concurrent submitWorkflow calls.
|
|
929
|
+
* Uses a promise guard to prevent double-initialization race conditions.
|
|
930
|
+
*/
|
|
931
|
+
_ensureTLCI() {
|
|
932
|
+
if (!this._tlciInitP) {
|
|
933
|
+
this._tlciInitP = this._initTLCI();
|
|
934
|
+
}
|
|
935
|
+
return this._tlciInitP;
|
|
936
|
+
}
|
|
937
|
+
async _initTLCI() {
|
|
938
|
+
this._planCache = new PlanCache();
|
|
939
|
+
await this._planCache.init();
|
|
940
|
+
this._tlciCostTracker = new CostTracker(this._planCache);
|
|
941
|
+
const self = this;
|
|
942
|
+
const ollamaProvider = this._providers.find(p => p.costTier === 'free');
|
|
943
|
+
this._tlciDispatcher = new TLCIDispatcher(this._planCache, { autonomyLevel: 'full' },
|
|
944
|
+
// Tier 1: real code tools — no LLM call, no token cost
|
|
945
|
+
async (step) => {
|
|
946
|
+
const classifiedStep = step;
|
|
947
|
+
const result = await runCodeToolStep({
|
|
948
|
+
id: step.id,
|
|
949
|
+
suggestedCodeTool: classifiedStep.suggestedCodeTool,
|
|
950
|
+
context: step.context,
|
|
951
|
+
description: step.description,
|
|
952
|
+
});
|
|
953
|
+
if (!result.success) {
|
|
954
|
+
log.warn({ stepId: step.id, tool: result.tool, error: result.error }, 'code-tool step failed');
|
|
955
|
+
}
|
|
956
|
+
return result;
|
|
957
|
+
},
|
|
958
|
+
// Tier 2: Ollama (local, free) — route via maxCostTier:'free'
|
|
959
|
+
// Falls back to frontier only on connection refusal / unavailability
|
|
960
|
+
async (step) => {
|
|
961
|
+
if (ollamaProvider) {
|
|
962
|
+
try {
|
|
963
|
+
const available = await ollamaProvider.isAvailable();
|
|
964
|
+
if (available) {
|
|
965
|
+
return await self.submitTask({ prompt: step.description, maxCostTier: 'free' });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
catch (err) {
|
|
969
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
970
|
+
// Only fall back for connection errors, not execution failures
|
|
971
|
+
if (/ECONNREFUSED|ENOTFOUND|timeout|unavailable/i.test(msg)) {
|
|
972
|
+
log.warn({ stepId: step.id, err: msg }, 'Ollama unreachable — falling back to frontier');
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
throw err; // real execution error — propagate
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
log.warn({ stepId: step.id }, 'Ollama unavailable — falling back to frontier for SLM step');
|
|
980
|
+
return self.submitTask({ prompt: step.description });
|
|
981
|
+
},
|
|
982
|
+
// Tier 3: frontier — existing provider stack
|
|
983
|
+
async (step) => self.submitTask({ prompt: step.description }),
|
|
984
|
+
// Approval — auto in full-autonomy mode
|
|
985
|
+
async (_message) => true,
|
|
986
|
+
// Wire CostTracker so it records every dispatch
|
|
987
|
+
this._tlciCostTracker);
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* ERR-08: Resume a previously-failed task using its full serialized TaskContext.
|
|
991
|
+
*
|
|
992
|
+
* Unlike submitTask(), this skips the "Planning/Classification" phase entirely.
|
|
993
|
+
* The existing history and memoryContext are preserved so the provider can
|
|
994
|
+
* continue from the exact point of failure — State Continuity.
|
|
995
|
+
*
|
|
996
|
+
* Called by the RetryQueue poll to resume persisted task contexts.
|
|
997
|
+
*
|
|
998
|
+
* @param context - The full TaskContext as persisted by the RetryQueue
|
|
999
|
+
* @returns The final text result from the resumed execution
|
|
1000
|
+
*/
|
|
1001
|
+
async _resumeTask(context, onEvent, compressor) {
|
|
1002
|
+
if (!this._booted)
|
|
1003
|
+
throw new Error('Orchestrator.boot() must be called before _resumeTask');
|
|
1004
|
+
log.info({ jobId: context.jobId, historyLength: context.history.length }, 'ERR-08: Resuming task with preserved context (skipping classification)');
|
|
1005
|
+
// Reset ValidationPipeline rate limit for the resumed session
|
|
1006
|
+
this._validationPipeline?.resetSession();
|
|
1007
|
+
// Refresh canUseTool — the original closure may be stale after a restart
|
|
1008
|
+
// SEC-10: Re-issue a capability token for the resumed job and apply token-aware enforcement
|
|
1009
|
+
const resumeJobId = context.jobId;
|
|
1010
|
+
const resumeCapToken = createCapabilityToken(resumeJobId, this._policy);
|
|
1011
|
+
this._activeTokens.set(resumeJobId, resumeCapToken);
|
|
1012
|
+
const resumeContext = {
|
|
1013
|
+
...context,
|
|
1014
|
+
canUseTool: this._buildTokenAwareCanUseTool(resumeJobId),
|
|
1015
|
+
};
|
|
1016
|
+
// Route to provider using the preserved classification (no re-classification)
|
|
1017
|
+
let selectedProvider;
|
|
1018
|
+
try {
|
|
1019
|
+
selectedProvider = await this._router.selectProvider(resumeContext);
|
|
1020
|
+
}
|
|
1021
|
+
catch (err) {
|
|
1022
|
+
this._activeTokens.delete(resumeJobId);
|
|
1023
|
+
throw new Error(`No provider available for resume: ${err instanceof Error ? err.message : String(err)}`);
|
|
1024
|
+
}
|
|
1025
|
+
// SEC-10: Clean up capability token after resumed task completes (success or failure)
|
|
1026
|
+
try {
|
|
1027
|
+
return await this._executeWithProvider(selectedProvider, resumeContext, onEvent, 0, 0, compressor);
|
|
1028
|
+
}
|
|
1029
|
+
finally {
|
|
1030
|
+
this._activeTokens.delete(resumeJobId);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
552
1033
|
/**
|
|
553
1034
|
* MEM-09: Runs memory extraction asynchronously after job completion.
|
|
554
1035
|
*
|
|
@@ -619,6 +1100,22 @@ export class Orchestrator {
|
|
|
619
1100
|
}
|
|
620
1101
|
log.info({ jobId: taskContext.jobId, extracted: result.items.length, saved: savedCount }, 'Memory extraction complete');
|
|
621
1102
|
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Execute with a failover provider while preserving the active intent capsule.
|
|
1105
|
+
* Serializes the capsule before handing off to the next provider and restores
|
|
1106
|
+
* it afterwards if the execution cleared it (defensive measure).
|
|
1107
|
+
*/
|
|
1108
|
+
async _executeWithFailoverProvider(nextProvider, taskContext, onEvent, failoverDepth, injectionDepth, compressor, patternDetector) {
|
|
1109
|
+
const capsuleSnapshot = this._intentCapsuleManager?.serializeActiveCapsule() ?? null;
|
|
1110
|
+
const result = await this._executeWithProvider(nextProvider, taskContext, onEvent, failoverDepth, injectionDepth, compressor, patternDetector);
|
|
1111
|
+
if (capsuleSnapshot && this._intentCapsuleManager && !this._intentCapsuleManager.getActiveCapsule()) {
|
|
1112
|
+
const restored = this._intentCapsuleManager.restoreCapsule(capsuleSnapshot);
|
|
1113
|
+
if (!restored) {
|
|
1114
|
+
log.warn({ jobId: taskContext.jobId }, 'Failed to restore intent capsule after failover — drift detection disabled for remainder of task');
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return result;
|
|
1118
|
+
}
|
|
622
1119
|
/**
|
|
623
1120
|
* Creates custom tools available to the agent during execution.
|
|
624
1121
|
* Includes: permission tools, memory tools (search/save/forget), recall_context.
|
|
@@ -706,7 +1203,40 @@ export class Orchestrator {
|
|
|
706
1203
|
return { notes, count: notes.length, days };
|
|
707
1204
|
},
|
|
708
1205
|
};
|
|
709
|
-
|
|
1206
|
+
// Skill tools: list_skills and invoke_skill for agent-callable skill library
|
|
1207
|
+
const skillTools = createSkillTools(this._policyEngine);
|
|
1208
|
+
// Wire plan_workflow tool — bound to submitWorkflow so the LLM can decompose
|
|
1209
|
+
// and optionally execute TLCI workflows mid-conversation.
|
|
1210
|
+
const planWorkflowTool = createPlanWorkflowTool((steps, opts) => this.submitWorkflow(steps, opts));
|
|
1211
|
+
// Subagent tools: list_subagents and delegate_to_subagent
|
|
1212
|
+
const subagentTools = createSubagentTools((opts) => this.submitTask({ prompt: opts.prompt }));
|
|
1213
|
+
return [...permissionTools, ...memoryTools, recallContextTool, ...skillTools, planWorkflowTool, ...subagentTools];
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* SEC-10: Builds a token-aware canUseTool function that enforces capability
|
|
1217
|
+
* token restrictions on path and command inputs before delegating to the
|
|
1218
|
+
* policy engine. Call this once per job to get a closure bound to jobId.
|
|
1219
|
+
*/
|
|
1220
|
+
_buildTokenAwareCanUseTool(jobId) {
|
|
1221
|
+
const policyCanUseTool = this._policyEngine.createCanUseTool();
|
|
1222
|
+
return async (tool, input, options) => {
|
|
1223
|
+
const token = this._activeTokens.get(jobId);
|
|
1224
|
+
if (token) {
|
|
1225
|
+
const pathArg = input['path'];
|
|
1226
|
+
if (pathArg) {
|
|
1227
|
+
const capResult = enforceCapability(token, { type: 'path', target: pathArg });
|
|
1228
|
+
if (!capResult.allowed)
|
|
1229
|
+
return { behavior: 'deny', message: capResult.reason ?? 'Path denied by capability token' };
|
|
1230
|
+
}
|
|
1231
|
+
const cmdArg = input['command'];
|
|
1232
|
+
if (cmdArg) {
|
|
1233
|
+
const capResult = enforceCapability(token, { type: 'command', target: cmdArg });
|
|
1234
|
+
if (!capResult.allowed)
|
|
1235
|
+
return { behavior: 'deny', message: capResult.reason ?? 'Command denied by capability token' };
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return policyCanUseTool(tool, input, options);
|
|
1239
|
+
};
|
|
710
1240
|
}
|
|
711
1241
|
/**
|
|
712
1242
|
* Parse interval strings like "30m", "1h" to minutes.
|
|
@@ -763,6 +1293,10 @@ export class Orchestrator {
|
|
|
763
1293
|
get hookRunner() {
|
|
764
1294
|
return this._hookRunner;
|
|
765
1295
|
}
|
|
1296
|
+
/** Register a tool-level lifecycle hook (fires before/after every tool call) */
|
|
1297
|
+
registerToolHook(hook) {
|
|
1298
|
+
this._toolHookRunner.register(hook);
|
|
1299
|
+
}
|
|
766
1300
|
/** ORCH-14: Set a custom context transform function */
|
|
767
1301
|
set transformContext(fn) {
|
|
768
1302
|
this._transformContext = fn;
|
|
@@ -771,11 +1305,43 @@ export class Orchestrator {
|
|
|
771
1305
|
get transformContext() {
|
|
772
1306
|
return this._transformContext;
|
|
773
1307
|
}
|
|
1308
|
+
/** SEC-11: Access the IntegrityGuardian for baseline and tamper checks */
|
|
1309
|
+
get integrityGuardian() {
|
|
1310
|
+
this._assertBooted();
|
|
1311
|
+
return this._integrityGuardian;
|
|
1312
|
+
}
|
|
1313
|
+
/** SEC-11: Re-save integrity baselines after intentional config changes */
|
|
1314
|
+
async rebaselineIntegrity() {
|
|
1315
|
+
this._assertBooted();
|
|
1316
|
+
await this._integrityGuardian.saveBaseline();
|
|
1317
|
+
log.info('Integrity baselines updated');
|
|
1318
|
+
}
|
|
1319
|
+
/** SEC-12: Access the SecretsManager (undefined if ZORA_MASTER_PASSWORD not set) */
|
|
1320
|
+
get secretsManager() {
|
|
1321
|
+
return this._secretsManager;
|
|
1322
|
+
}
|
|
774
1323
|
get config() {
|
|
775
1324
|
return this._config;
|
|
776
1325
|
}
|
|
777
1326
|
get providers() {
|
|
778
1327
|
return this._providers;
|
|
779
1328
|
}
|
|
1329
|
+
// ── Private helpers ──────────────────────────────────────────────
|
|
1330
|
+
/**
|
|
1331
|
+
* MEM-20: Build the compressFn used by ContextCompressor and ReflectorWorker.
|
|
1332
|
+
* Extracted so it can be reused in boot() (for ReflectorWorker) and submitTask().
|
|
1333
|
+
*/
|
|
1334
|
+
_buildCompressFn() {
|
|
1335
|
+
return async (prompt) => {
|
|
1336
|
+
const compressLoop = new ExecutionLoop({
|
|
1337
|
+
systemPrompt: 'You are a conversation observer. Compress messages into concise, dated observations. Respond with ONLY the observations.',
|
|
1338
|
+
permissionMode: 'default',
|
|
1339
|
+
cwd: process.cwd(),
|
|
1340
|
+
maxTurns: 1,
|
|
1341
|
+
model: this._config.memory?.compression?.model,
|
|
1342
|
+
});
|
|
1343
|
+
return compressLoop.run(prompt);
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
780
1346
|
}
|
|
781
1347
|
//# sourceMappingURL=orchestrator.js.map
|