@arthovlabs/liber 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -2
  3. package/bin/liber +706 -0
  4. package/bin/liber-tui +244 -0
  5. package/bin/liber-tui.py +825 -0
  6. package/bin/liberd-entry +17 -0
  7. package/liber.yaml +84 -0
  8. package/package.json +43 -4
  9. package/resources/icons/appIcon.png +0 -0
  10. package/resources/icons/appIcon.svg +20 -0
  11. package/resources/icons/appIcon@512.png +0 -0
  12. package/resources/icons/atom-transparent-256.png +0 -0
  13. package/resources/icons/atom-transparent.png +0 -0
  14. package/resources/icons/atom-transparent.svg +19 -0
  15. package/resources/icons/make-atom.js +168 -0
  16. package/src/cli/ui.js +520 -0
  17. package/src/daemon/core/auth.js +217 -0
  18. package/src/daemon/core/automations.js +130 -0
  19. package/src/daemon/core/budget.js +145 -0
  20. package/src/daemon/core/circuit-breaker.js +31 -0
  21. package/src/daemon/core/config-apply.js +223 -0
  22. package/src/daemon/core/connectors.js +131 -0
  23. package/src/daemon/core/engine.js +1029 -0
  24. package/src/daemon/core/evidence.js +306 -0
  25. package/src/daemon/core/executor.js +10 -0
  26. package/src/daemon/core/grounding.js +420 -0
  27. package/src/daemon/core/injection.js +24 -0
  28. package/src/daemon/core/journal.js +81 -0
  29. package/src/daemon/core/liber-soul.js +157 -0
  30. package/src/daemon/core/lock.js +32 -0
  31. package/src/daemon/core/memory.js +106 -0
  32. package/src/daemon/core/model-switch.js +58 -0
  33. package/src/daemon/core/policy.js +102 -0
  34. package/src/daemon/core/providers.js +503 -0
  35. package/src/daemon/core/registry.js +356 -0
  36. package/src/daemon/core/secrets.js +35 -0
  37. package/src/daemon/core/skills.js +264 -0
  38. package/src/daemon/core/tools/browser-tool.js +280 -0
  39. package/src/daemon/core/tools/file-tools.js +277 -0
  40. package/src/daemon/core/tools/memory-tool.js +127 -0
  41. package/src/daemon/core/tools/skill-tool.js +44 -0
  42. package/src/daemon/core/tools/task-tool.js +41 -0
  43. package/src/daemon/core/tools/terminal-tool.js +449 -0
  44. package/src/daemon/core/tools/todo-tool.js +100 -0
  45. package/src/daemon/core/tools/web-tool.js +181 -0
  46. package/src/daemon/core/trace.js +40 -0
  47. package/src/daemon/core/validate.js +105 -0
  48. package/src/daemon/core/vault.js +158 -0
  49. package/src/daemon/core/verifier.js +98 -0
  50. package/src/daemon/core/voice.js +83 -0
  51. package/src/daemon/core/workspace.js +199 -0
  52. package/src/daemon/liberd.js +1343 -0
  53. package/src/daemon/llm/adapters/anthropic-adapter.js +190 -0
  54. package/src/daemon/llm/adapters/gemini-adapter.js +148 -0
  55. package/src/daemon/llm/adapters/ollama-adapter.js +74 -0
  56. package/src/daemon/llm/adapters/openai-adapter.js +148 -0
  57. package/src/daemon/llm/base.js +31 -0
  58. package/src/daemon/llm/manager.js +134 -0
  59. package/src/utils/config-loader.js +109 -0
  60. package/src/utils/logger.js +56 -0
@@ -0,0 +1,1029 @@
1
+ // src/daemon/core/engine.js — Liber Autonomous Turn Loop + Grounding Gate
2
+ const registry = require('./registry');
3
+ const circuitBreaker = require('./circuit-breaker');
4
+ const logger = require('../../utils/logger');
5
+ const { buildSystemPrompt } = require('./liber-soul');
6
+ const { EvidenceLedger } = require('./evidence');
7
+ const grounding = require('./grounding');
8
+ const skills = require('./skills');
9
+ const budget = require('./budget');
10
+ const verifier = require('./verifier');
11
+ const journal = require('./journal');
12
+ const injection = require('./injection');
13
+
14
+ const DEFAULT_GROUNDING = {
15
+ enabled: true,
16
+ max_repairs: 2,
17
+ require_readback: true,
18
+ require_citations: true,
19
+ semantic_verifier: false,
20
+ doctrine_budget_chars: 7000
21
+ };
22
+
23
+ // Subagents are read/verify workers: no mutations, no recursion, no HITL prompts.
24
+ const SUBAGENT_TOOLS = ['read_file', 'search_files', 'terminal', 'fetch_url', 'todo_list', 'memory'];
25
+ const SUBAGENT_MAX_LOOPS = 6;
26
+
27
+ class LiberEngine {
28
+ /**
29
+ * Parse tool calls. Bracket/quote-aware so JSON strings containing ")" survive.
30
+ */
31
+ parseToolCalls(text) {
32
+ const calls = [];
33
+ const src = String(text);
34
+ const re = /\[TOOL:\s*([a-zA-Z0-9_\-]+)\s*\(/g;
35
+ let m;
36
+
37
+ while ((m = re.exec(src)) !== null) {
38
+ const name = m[1];
39
+ const start = m.index + m[0].length;
40
+ let depth = 1;
41
+ let i = start;
42
+ let quote = null;
43
+ let escaped = false;
44
+
45
+ for (; i < src.length; i++) {
46
+ const ch = src[i];
47
+ if (quote) {
48
+ if (escaped) escaped = false;
49
+ else if (ch === '\\') escaped = true;
50
+ else if (ch === quote) quote = null;
51
+ continue;
52
+ }
53
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
54
+ if (ch === '(') depth++;
55
+ else if (ch === ')') {
56
+ depth--;
57
+ if (depth === 0) break;
58
+ }
59
+ }
60
+ if (depth !== 0) continue;
61
+
62
+ const argText = src.slice(start, i);
63
+ let args;
64
+ try {
65
+ args = JSON.parse(argText);
66
+ } catch {
67
+ args = { raw: argText.trim() };
68
+ }
69
+ calls.push({ name, args, raw: src.slice(m.index, i + 1) });
70
+ re.lastIndex = i + 1;
71
+ }
72
+
73
+ const shortcut = (regex, name, build) => {
74
+ let match;
75
+ while ((match = regex.exec(src)) !== null) {
76
+ calls.push({ name, args: build(match), raw: match[0] });
77
+ }
78
+ };
79
+
80
+ shortcut(/\[EXEC:\s*([\s\S]*?)\]/g, 'terminal', mm => ({ command: mm[1].trim() }));
81
+ shortcut(/\[READ:\s*([^\s\]]+)\]/g, 'read_file', mm => ({ path: mm[1].trim() }));
82
+ shortcut(/\[WRITE:\s*([^\s\]]+)\]([\s\S]*?)\[\/WRITE\]/g, 'write_file', mm => ({ path: mm[1].trim(), content: mm[2].trim() }));
83
+
84
+ return calls;
85
+ }
86
+
87
+ /**
88
+ * Balanced-bracket slice starting at an opening '['.
89
+ * Handles citations like [E1] inside a [THOUGHT: ...] block.
90
+ */
91
+ balancedSlice(src, start) {
92
+ let depth = 0;
93
+ for (let i = start; i < src.length; i++) {
94
+ const ch = src[i];
95
+ if (ch === '[') depth++;
96
+ else if (ch === ']') {
97
+ depth--;
98
+ if (depth === 0) return src.slice(start, i + 1);
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ stripThoughts(text) {
105
+ let out = String(text);
106
+ let idx;
107
+ while ((idx = out.search(/\[THOUGHT:/i)) >= 0) {
108
+ const block = this.balancedSlice(out, idx);
109
+ if (!block) break;
110
+ out = out.slice(0, idx) + out.slice(idx + block.length);
111
+ }
112
+ return out
113
+ .replace(/<thought>[\s\S]*?<\/thought>/gi, '')
114
+ .replace(/<\/?thought>/gi, '');
115
+ }
116
+
117
+ /**
118
+ * Native tool-call arguments arrive as a JSON string (or object on some providers).
119
+ */
120
+ parseNativeArgs(raw) {
121
+ if (raw && typeof raw === 'object') return raw;
122
+ try {
123
+ const parsed = JSON.parse(String(raw || '{}'));
124
+ return parsed && typeof parsed === 'object' ? parsed : { raw: String(raw || '') };
125
+ } catch {
126
+ return { raw: String(raw || '') };
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Short reply with no numbers, paths, versions or code identifiers: there is
132
+ * nothing for a second LLM pass to verify, and paying for one is what made a
133
+ * greeting feel slow.
134
+ */
135
+ isClaimFree(text) {
136
+ const s = String(text || '').trim();
137
+ if (!s) return false;
138
+ if (s.length > 140) return false;
139
+ return !/[0-9]|\/|\\|\.[a-z]{2,4}\b|[A-Za-z]{2,}@|[A-Z]{2,}/.test(s);
140
+ }
141
+
142
+ extractThought(text) {
143
+ const blocks = this.extractThoughts(text);
144
+ return blocks.length ? blocks[0] : null;
145
+ }
146
+
147
+ /**
148
+ * Every reasoning block in one message, in order.
149
+ * A turn may think before each tool batch, so there is usually more than one.
150
+ */
151
+ extractThoughts(text) {
152
+ const src = String(text);
153
+ const blocks = [];
154
+ const re = /\[THOUGHT:/gi;
155
+ let m;
156
+ while ((m = re.exec(src)) !== null) {
157
+ const block = this.balancedSlice(src, m.index);
158
+ if (!block) break;
159
+ const body = block.replace(/^\[THOUGHT:\s*/i, '').replace(/\]\s*$/, '').trim();
160
+ if (body) blocks.push(body);
161
+ re.lastIndex = m.index + block.length;
162
+ }
163
+ for (const t of src.matchAll(/<thought>([\s\S]*?)<\/thought>/gi)) {
164
+ const body = t[1].trim();
165
+ if (body) blocks.push(body);
166
+ }
167
+ return blocks;
168
+ }
169
+
170
+ /**
171
+ * Capture reasoning whether or not the model used a [THOUGHT:] tag.
172
+ * Untagged prose before the first tool/final marker is real reasoning and must be shown.
173
+ */
174
+ extractReasoning(text) {
175
+ const src = String(text);
176
+ const tagged = this.extractThoughts(src);
177
+
178
+ const marker = src.search(/\[(?:TOOL|EXEC|READ|WRITE|FINAL):/i);
179
+ let preamble = '';
180
+ if (marker > 0) {
181
+ preamble = this.stripThoughts(src.slice(0, marker))
182
+ .replace(/<\/?thought>/gi, ' ')
183
+ .trim();
184
+ }
185
+
186
+ if (tagged.length) {
187
+ const joined = tagged.join('\n\n');
188
+ return { content: preamble ? `${joined}\n\n${preamble}` : joined, kind: 'tagged' };
189
+ }
190
+ if (preamble.length >= 24) {
191
+ return { content: preamble, kind: 'preamble' };
192
+ }
193
+ return null;
194
+ }
195
+
196
+ /**
197
+ * Extract [FINAL: ...] without truncating on citation brackets like [E1].
198
+ */
199
+ extractFinal(text) {
200
+ const src = String(text);
201
+ const m = src.match(/\[FINAL:\s*/i) || src.match(/^[ \t]*FINAL:\s*/im);
202
+ if (!m) return null;
203
+
204
+ let body = src.slice(m.index + m[0].length).trimEnd();
205
+ if (body.endsWith(']')) {
206
+ const opens = (body.match(/\[/g) || []).length;
207
+ const closes = (body.match(/\]/g) || []).length;
208
+ // Drop only the wrapper's own closing bracket, not a citation's.
209
+ if (closes === opens + 1) body = body.slice(0, -1).trimEnd();
210
+ }
211
+ return body.trim();
212
+ }
213
+
214
+ cleanAnswer(text) {
215
+ return this.stripThoughts(String(text))
216
+ .replace(/\[\/?(?:TOOL|EXEC|READ|WRITE|FINAL):[\s\S]*?\]/gi, '')
217
+ .trim();
218
+ }
219
+
220
+ /**
221
+ * Compress conversation history if token pressure is high.
222
+ */
223
+ compressContext(messages, maxTokens = 14000) {
224
+ const estimatedChars = JSON.stringify(messages).length;
225
+ const estimatedTokens = Math.floor(estimatedChars / 3.8);
226
+
227
+ if (estimatedTokens <= maxTokens || messages.length <= 6) {
228
+ return messages;
229
+ }
230
+
231
+ const systemMsg = messages[0];
232
+ const recent = messages.slice(-6);
233
+ const middle = messages.slice(1, -6);
234
+
235
+ const summaryContent = middle
236
+ .map(m => {
237
+ if (Array.isArray(m.tool_calls) && m.tool_calls.length) {
238
+ return `ASSISTANT: [calls ${m.tool_calls.map(t => t.function?.name || 'tool').join(', ')}]`;
239
+ }
240
+ const text = String(m.content || '');
241
+ if (m.role === 'user' && text.startsWith('OBSERVATION')) return `TOOL: ${text.slice(0, 200)}`;
242
+ if (text.startsWith('GROUNDING AUDIT') || text.startsWith('DUPLICATE CALL')) return `SYSTEM: ${text.slice(0, 160)}`;
243
+ return `${m.role.toUpperCase()}: ${text.slice(0, 160)}`;
244
+ })
245
+ .join(' | ');
246
+
247
+ const summaryMessage = {
248
+ role: 'system',
249
+ content: `[CONTEXT COMPRESSION — earlier turns summarised; receipts remain in the Evidence Ledger] ${summaryContent.slice(0, 1400)}`
250
+ };
251
+
252
+ return [systemMsg, summaryMessage, ...recent];
253
+ }
254
+
255
+ clip(text, max = 2000) {
256
+ const s = String(text || '');
257
+ return s.length > max ? s.slice(0, max) + '\n... [TRUNCATED]' : s;
258
+ }
259
+
260
+ /**
261
+ * Resume an interrupted turn from the journal.
262
+ */
263
+ async resume(agentId, { agentConfig, memory, llmManager, onEvent = () => {}, maxLoops = 12, shouldStop = null, waitForHitl = null }) {
264
+ const state = journal.load(agentId);
265
+ if (!state) {
266
+ return { success: false, error: `No interrupted turn for '${agentId}'.` };
267
+ }
268
+ return this.runTurn({
269
+ agentId,
270
+ agentConfig,
271
+ userMessage: state.userMessage,
272
+ memory,
273
+ llmManager,
274
+ onEvent,
275
+ maxLoops,
276
+ resumeState: state,
277
+ shouldStop,
278
+ // Must be forwarded: a resumed turn can hit the same sensitive tools, and
279
+ // the runTurn default (deny) would otherwise block every one of them.
280
+ ...(typeof waitForHitl === 'function' ? { waitForHitl } : {})
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Execute `task` / `tasks`: isolated child turns with their own ledger and context.
286
+ */
287
+ async runSubagentTool({ call, agentConfig, llmManager, depth, onEvent, parentId, seq }) {
288
+ if (depth >= 1) {
289
+ return { error: 'Subagent depth limit reached: a subagent cannot spawn another subagent.' };
290
+ }
291
+
292
+ if (call.name === 'task') {
293
+ const goal = String(call.args.goal || '').trim();
294
+ if (!goal) return { error: "Missing 'goal' argument." };
295
+ const result = await this.runSubagent({
296
+ goal,
297
+ context: call.args.context || '',
298
+ agentConfig,
299
+ llmManager,
300
+ depth,
301
+ onEvent,
302
+ parentId,
303
+ seq
304
+ });
305
+ return { mode: 'single', result };
306
+ }
307
+
308
+ const list = Array.isArray(call.args.tasks) ? call.args.tasks : [];
309
+ if (!list.length) return { error: "Missing non-empty 'tasks' array." };
310
+ if (list.length > 4) return { error: 'At most 4 parallel subagents per call.' };
311
+
312
+ const results = await Promise.all(list.map((task, i) => {
313
+ const goal = String(task?.goal || '').trim();
314
+ if (!goal) return { error: `Task ${i + 1} is missing 'goal'.` };
315
+ return this.runSubagent({
316
+ goal,
317
+ context: task?.context || '',
318
+ agentConfig,
319
+ llmManager,
320
+ depth,
321
+ onEvent,
322
+ parentId,
323
+ seq: `${seq}-${i + 1}`
324
+ });
325
+ }));
326
+
327
+ return { mode: 'parallel', results };
328
+ }
329
+
330
+ async runSubagent({ goal, context, agentConfig, llmManager, depth, onEvent, parentId, seq }) {
331
+ const childId = `${parentId}::sub${seq}`;
332
+
333
+ const store = new Map();
334
+ const childMemory = {
335
+ add(agentId, role, content) {
336
+ const arr = store.get(agentId) || [];
337
+ arr.push({ role, content });
338
+ while (arr.length > 20) arr.shift();
339
+ store.set(agentId, arr);
340
+ },
341
+ get(agentId) {
342
+ return store.get(agentId) || [];
343
+ }
344
+ };
345
+
346
+ const childConfig = {
347
+ ...agentConfig,
348
+ name: `sub-${seq}`,
349
+ system_prompt: `${agentConfig.system_prompt || ''}\nSUBAGENT MODE. Complete only the assigned goal, verify with tools, cite receipts, and return a concise report. You cannot spawn subagents.`,
350
+ engine: { ...agentConfig.engine, stream: false },
351
+ grounding: { ...(agentConfig.grounding || {}), semantic_verifier: false }
352
+ };
353
+
354
+ onEvent('subagent_start', { id: childId, goal: goal.slice(0, 220) });
355
+
356
+ try {
357
+ const res = await this.runTurn({
358
+ agentId: childId,
359
+ agentConfig: childConfig,
360
+ userMessage: `${goal}\n\nContext:\n${context || '(none)'}`,
361
+ memory: childMemory,
362
+ llmManager,
363
+ onEvent: () => {},
364
+ waitForHitl: async () => false,
365
+ maxLoops: SUBAGENT_MAX_LOOPS,
366
+ depth: depth + 1,
367
+ allowedTools: SUBAGENT_TOOLS
368
+ });
369
+
370
+ const report = {
371
+ id: childId,
372
+ goal,
373
+ grounded: res.grounded,
374
+ status: res.grounding?.status || null,
375
+ response: this.clip(res.response, 2000),
376
+ receipts: (res.grounding?.ledger?.receipts || []).map(r => `${r.id}:${r.tool}`)
377
+ };
378
+ onEvent('subagent_end', { id: childId, status: report.status, grounded: report.grounded });
379
+ return report;
380
+ } catch (err) {
381
+ onEvent('subagent_end', { id: childId, status: 'ERROR', error: err.message });
382
+ return { id: childId, goal, error: err.message };
383
+ }
384
+ }
385
+
386
+ async runTurn({
387
+ agentId,
388
+ memoryKey = null,
389
+ agentConfig,
390
+ userMessage,
391
+ memory,
392
+ llmManager,
393
+ onEvent = () => {},
394
+ // Fail closed: a caller that forgets to supply an approval channel must not
395
+ // silently authorize sensitive tools.
396
+ waitForHitl = async () => false,
397
+ maxLoops = 12,
398
+ depth = 0,
399
+ allowedTools = null,
400
+ resumeState = null,
401
+ shouldStop = null
402
+ }) {
403
+ const resumed = !!resumeState;
404
+ // The client can hang up mid-turn (stop button / closed window). Checked
405
+ // between iterations so we stop spending tokens as soon as it does.
406
+ const stopRequested = () => {
407
+ try { return typeof shouldStop === 'function' && shouldStop() === true; } catch { return false; }
408
+ };
409
+ let stopped = false;
410
+ // Session history lives under its own key so a new session starts empty;
411
+ // tracing, evidence and the breaker stay keyed to the real agent.
412
+ const memKey = memoryKey || agentId;
413
+ const cfg = { ...DEFAULT_GROUNDING, ...(agentConfig.grounding || {}) };
414
+ const ledger = resumed ? EvidenceLedger.fromJSON(resumeState.ledger) : new EvidenceLedger(agentId);
415
+ const skillCtx = skills.buildContext(String(userMessage || ''), cfg.doctrine_budget_chars);
416
+ // Standing facts are injected every turn, not fetched on demand: a new
417
+ // session should open already knowing the user and this workspace.
418
+ let memoryBlockText = '';
419
+ try {
420
+ memoryBlockText = require('./tools/memory-tool').memoryBlock(1400) || '';
421
+ } catch { /* memory is optional */ }
422
+
423
+ circuitBreaker.configureLimit(agentId, maxLoops);
424
+ circuitBreaker.reset(agentId);
425
+
426
+ if (!resumed) {
427
+ memory.add(memKey, 'user', userMessage);
428
+ }
429
+ logger.info(agentId, 'USER_MESSAGE_START', { userMessage, resumed });
430
+
431
+ let messages;
432
+ if (resumed && Array.isArray(resumeState.messages) && resumeState.messages.length) {
433
+ messages = resumeState.messages.map(m => ({ ...m }));
434
+ } else {
435
+ const history = memory.get(memKey).map(m => ({ role: m.role, content: m.content }));
436
+ messages = [{ role: 'system', content: '' }, ...history];
437
+ }
438
+
439
+ let finalResponse = '';
440
+ let auditResult = null;
441
+ let repairs = resumed ? Number(resumeState.repairs || 0) : 0;
442
+ let iteration = resumed ? Number(resumeState.iteration || 0) : 0;
443
+ const trace = [];
444
+ const requestStartTime = Date.now();
445
+ const callCounts = new Map();
446
+ const usageTotals = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
447
+ let streamedAny = false;
448
+ let nativeTools = agentConfig.engine?.native_tools === true;
449
+ let subSeq = 0;
450
+ let finalAsked = false; // one protocol nudge per turn, never more
451
+ // A turn can switch model mid-flight when the provider rejects the primary
452
+ // one; the client should see that instead of wondering why the voice changed.
453
+ const fallbackTrail = [];
454
+ let lastFallbackAt = 0;
455
+ let hurryAsked = false; // one "converge now" nudge per turn
456
+
457
+ const persistTurn = () => {
458
+ if (cfg.journal === false) return;
459
+ journal.save({
460
+ agentId,
461
+ userMessage,
462
+ messages,
463
+ ledger: ledger.toJSON(),
464
+ repairs,
465
+ iteration
466
+ });
467
+ };
468
+
469
+ onEvent('start', {
470
+ agentId,
471
+ model: agentConfig.engine?.model,
472
+ grounding: !!cfg.enabled,
473
+ max_loops: maxLoops,
474
+ skills: skillCtx.used,
475
+ resumed
476
+ });
477
+
478
+ while (iteration < maxLoops) {
479
+ if (circuitBreaker.isBroken(agentId)) break;
480
+
481
+ if (stopRequested()) {
482
+ stopped = true;
483
+ onEvent('stopped', { iteration, receipts: ledger.entries.length });
484
+ logger.warn(agentId, 'TURN_STOPPED', { iteration, receipts: ledger.entries.length });
485
+ break;
486
+ }
487
+
488
+ const budgetState = budget.check(agentConfig.limits || {}, agentConfig.engine || {});
489
+ if (!budgetState.allowed) {
490
+ finalResponse = `${budgetState.reason} No further model calls were made this turn. Receipts: ${
491
+ ledger.entries.length ? ledger.entries.map(e => e.id).join(', ') : 'none'
492
+ }.`;
493
+ onEvent('budget_blocked', budgetState);
494
+ logger.error(agentId, 'BUDGET_BLOCKED', budgetState);
495
+ break;
496
+ }
497
+
498
+ // Converge instead of spending the last iterations exploring: with two
499
+ // iterations left the model is told to answer from what it already has.
500
+ if (!hurryAsked && iteration >= maxLoops - 2 && ledger.entries.length && !finalResponse) {
501
+ hurryAsked = true;
502
+ const hurry = [
503
+ `You have at most ${maxLoops - iteration} iteration(s) left.`,
504
+ 'Stop exploring. Answer the user now as one [FINAL: ...] block using the receipts you already hold;',
505
+ 'cite each factual claim or write UNVERIFIED: <claim>.'
506
+ ].join(' ');
507
+ messages.push({ role: 'user', content: hurry });
508
+ memory.add(memKey, 'user', hurry);
509
+ onEvent('converging', { iteration, maxLoops, receipts: ledger.entries.length });
510
+ }
511
+
512
+ messages = this.compressContext(messages, agentConfig.engine?.context_budget || 14000);
513
+
514
+ const systemContent = buildSystemPrompt(agentConfig, registry.getToolDescription(), {
515
+ ledger,
516
+ skillContext: skillCtx.text,
517
+ memoryBlock: memoryBlockText,
518
+ nativeTools
519
+ });
520
+ if (messages[0] && messages[0].role === 'system') messages[0] = { role: 'system', content: systemContent };
521
+ else messages.unshift({ role: 'system', content: systemContent });
522
+
523
+ const estTokens = Math.floor(JSON.stringify(messages).length / 3.8);
524
+ onEvent('telemetry', {
525
+ ctx_used: estTokens,
526
+ ctx_total: agentConfig.engine?.context_window_override > 0
527
+ ? agentConfig.engine.context_window_override
528
+ : (agentConfig.engine?.context_window || 32000),
529
+ latency: Date.now() - requestStartTime,
530
+ iteration: iteration + 1,
531
+ max_iterations: maxLoops,
532
+ receipts: ledger.entries.length,
533
+ active_node: `LiberCore #${iteration + 1}`
534
+ });
535
+
536
+ const llmStart = Date.now();
537
+ let response = '';
538
+ let nativeCalls = null;
539
+ // Provider-native chain of thought (reasoning_content / thinking_delta).
540
+ let reasoningStream = '';
541
+ let reasoningLive = false;
542
+
543
+ if (nativeTools) {
544
+ try {
545
+ const nativeRes = await llmManager.generateChat(messages, { tools: registry.getOpenAITools() });
546
+ response = nativeRes.content || '';
547
+ nativeCalls = nativeRes.tool_calls || null;
548
+ } catch (err) {
549
+ logger.warn(agentId, 'NATIVE_TOOLS_UNAVAILABLE', { error: err.message });
550
+ onEvent('native_tools_unavailable', { error: err.message });
551
+ nativeTools = false;
552
+ }
553
+ }
554
+
555
+ if (!nativeTools && agentConfig.engine?.stream === true && typeof llmManager.stream === 'function') {
556
+ try {
557
+ let started = false;
558
+ await llmManager.stream(messages, (delta) => {
559
+ if (!delta) return;
560
+ if (!started) {
561
+ started = true;
562
+ onEvent('draft_start', { model: agentConfig.engine?.model });
563
+ }
564
+ response += delta;
565
+ onEvent('draft', { delta });
566
+ }, {
567
+ // Live reasoning: stream it to the client exactly as the model emits it.
568
+ onReasoning: (text) => {
569
+ if (!text) return;
570
+ if (!reasoningLive) {
571
+ reasoningLive = true;
572
+ onEvent('reasoning_start', { model: agentConfig.engine?.model });
573
+ }
574
+ reasoningStream += text;
575
+ onEvent('reasoning', { delta: text });
576
+ }
577
+ });
578
+ if (started) {
579
+ streamedAny = true;
580
+ onEvent('draft_end', { chars: response.length });
581
+ }
582
+ if (reasoningLive) onEvent('reasoning_end', { chars: reasoningStream.length });
583
+ } catch (err) {
584
+ logger.error(agentId, 'LLM_STREAM_ERROR', { error: err.message, chars: response.length });
585
+ onEvent('draft_end', { chars: response.length, failed: true });
586
+ // A partial stream is not trustworthy. Discard it and get a complete answer.
587
+ response = '';
588
+ }
589
+ }
590
+
591
+ if (!response && !(nativeCalls && nativeCalls.length)) {
592
+ try {
593
+ response = await llmManager.generate(messages);
594
+ } catch (err) {
595
+ logger.error(agentId, 'LLM_ERROR', { error: err.message });
596
+ response = `Error: LLM provider unavailable (${err.message})`;
597
+ }
598
+ }
599
+ // the manager may have moved to a fallback model while producing this reply
600
+ if (llmManager.lastFallback && llmManager.lastFallback.at !== lastFallbackAt) {
601
+ lastFallbackAt = llmManager.lastFallback.at;
602
+ fallbackTrail.push({ ...llmManager.lastFallback });
603
+ onEvent('fallback', { ...llmManager.lastFallback });
604
+ }
605
+ const llmMs = Math.max(1, Date.now() - llmStart);
606
+ const usage = typeof llmManager.getLastUsage === 'function' ? llmManager.getLastUsage() : null;
607
+ if (usage) {
608
+ usageTotals.prompt_tokens += Number(usage.prompt_tokens || 0);
609
+ usageTotals.completion_tokens += Number(usage.completion_tokens || 0);
610
+ usageTotals.total_tokens += Number(usage.total_tokens || 0);
611
+ budget.record(usage, agentConfig.engine || {});
612
+ }
613
+ onEvent('memory', { chars: memoryBlockText.length });
614
+ onEvent('telemetry', {
615
+ // Measured, not invented: estimated output tokens / real wall time.
616
+ tok_speed: parseFloat(((String(response).length / 4) / (llmMs / 1000)).toFixed(1)),
617
+ latency_ms: llmMs,
618
+ prompt_tokens: usage?.prompt_tokens ?? null,
619
+ completion_tokens: usage?.completion_tokens ?? null
620
+ });
621
+
622
+ let reasoning = null;
623
+ if (reasoningStream.trim()) {
624
+ // Already streamed to the client as its own block — do not repeat it as a step.
625
+ reasoning = { content: reasoningStream.trim(), kind: 'reasoning' };
626
+ } else if (nativeCalls && nativeCalls.length) {
627
+ if (response.trim()) reasoning = { content: response.trim(), kind: 'native' };
628
+ } else {
629
+ reasoning = this.extractReasoning(response);
630
+ }
631
+ if (reasoning) {
632
+ trace.push({ type: 'thought', content: reasoning.content, kind: reasoning.kind });
633
+ if (!reasoningLive) onEvent('thought', { content: reasoning.content, kind: reasoning.kind });
634
+ }
635
+
636
+ const explicitFinal = this.extractFinal(response);
637
+ const hasNativeCalls = !!(nativeCalls && nativeCalls.length);
638
+ const textToolCalls = explicitFinal === null && !hasNativeCalls ? this.parseToolCalls(response) : [];
639
+
640
+ // ── Candidate answer: audit before delivery ──────────────────────────
641
+ if (!hasNativeCalls && (explicitFinal !== null || textToolCalls.length === 0)) {
642
+ const candidate = explicitFinal !== null ? explicitFinal : this.cleanAnswer(response);
643
+
644
+ // An empty answer cannot be audited or delivered — nudge and retry
645
+ // instead of shipping a bogus "execution limit" report.
646
+ if (!String(candidate || '').trim()) {
647
+ const nudge = [
648
+ 'You returned an empty response with no tool calls. That cannot be delivered.',
649
+ 'Produce your answer as [FINAL: ...] citing receipts, or keep working with tools.'
650
+ ].join(' ');
651
+ memory.add(memKey, 'user', nudge);
652
+ messages.push({ role: 'user', content: nudge });
653
+ trace.push({ type: 'loop_guard', tool: 'empty-answer' });
654
+ onEvent('loop_guard', { tool: 'empty-answer' });
655
+ circuitBreaker.increment(agentId);
656
+ iteration++;
657
+ persistTurn();
658
+ continue;
659
+ }
660
+
661
+ // The protocol answer is [FINAL: ...]. Without it the model's text is
662
+ // process narration ("the ledger is empty", "the audit rejects…") and must
663
+ // never be delivered as the reply. Ask once for a real answer.
664
+ if (explicitFinal === null && !finalAsked) {
665
+ finalAsked = true;
666
+ const nudge = [
667
+ 'Your reply contained no [FINAL: ...] block, so the user would only see your internal process narration.',
668
+ 'Never describe the evidence ledger, the audit, receipt ids, [E#] choices or your formatting decisions in the answer.',
669
+ 'Write the actual reply now: one [FINAL: ...] block, short, in the user’s language.'
670
+ ].join(' ');
671
+ memory.add(memKey, 'user', nudge);
672
+ messages.push({ role: 'user', content: nudge });
673
+ trace.push({ type: 'loop_guard', tool: 'missing-final' });
674
+ onEvent('loop_guard', { tool: 'missing-final' });
675
+ logger.warn(agentId, 'MISSING_FINAL', { chars: String(response).length });
676
+ iteration++;
677
+ persistTurn();
678
+ continue;
679
+ }
680
+
681
+ let verdict = cfg.enabled
682
+ ? grounding.audit(candidate, ledger, {
683
+ requireReadback: cfg.require_readback,
684
+ requireCitations: cfg.require_citations
685
+ })
686
+ : { passed: true, status: 'DISABLED', violations: [], claims_checked: 0, receipts: ledger.entries.length, citations: [] };
687
+
688
+ // Second pass: does a receipt actually entail the claim, not merely exist?
689
+ // It costs a whole extra model call, so it is skipped for replies that make
690
+ // no auditable claim at all ("Hi.", "thanks", "what did you just say").
691
+ if (cfg.enabled && cfg.semantic_verifier && verdict.passed && ledger.entries.length && !this.isClaimFree(candidate)) {
692
+ try {
693
+ const sem = await verifier.verify({
694
+ candidate,
695
+ ledger,
696
+ llmManager,
697
+ disclosed: grounding.disclosedSpans(candidate)
698
+ });
699
+ if (sem.unsupported.length) {
700
+ verdict = {
701
+ ...verdict,
702
+ passed: false,
703
+ status: 'REPAIR',
704
+ semantic: 'fail',
705
+ violations: sem.unsupported.map(u => ({
706
+ code: 'SEMANTIC_UNSUPPORTED',
707
+ text: u.claim,
708
+ line: null,
709
+ hint: `Not entailed by the receipts${u.receipt ? ` (${u.receipt})` : ''}: ${u.reason || 'no support found'}. Verify it with a tool, remove it, or mark it UNVERIFIED.`
710
+ }))
711
+ };
712
+ } else {
713
+ verdict = { ...verdict, semantic: sem.parse_error ? 'error' : 'pass' };
714
+ }
715
+ } catch (err) {
716
+ logger.warn(agentId, 'SEMANTIC_VERIFIER_ERROR', { error: err.message });
717
+ verdict = { ...verdict, semantic: 'error' };
718
+ }
719
+
720
+ const verifyUsage = typeof llmManager.getLastUsage === 'function' ? llmManager.getLastUsage() : null;
721
+ if (verifyUsage) {
722
+ usageTotals.prompt_tokens += Number(verifyUsage.prompt_tokens || 0);
723
+ usageTotals.completion_tokens += Number(verifyUsage.completion_tokens || 0);
724
+ usageTotals.total_tokens += Number(verifyUsage.total_tokens || 0);
725
+ budget.record(verifyUsage, agentConfig.engine || {});
726
+ }
727
+ }
728
+
729
+ trace.push({
730
+ type: 'grounding',
731
+ status: verdict.status,
732
+ violations: verdict.violations.length,
733
+ repairs,
734
+ semantic: verdict.semantic || null
735
+ });
736
+
737
+ if (!verdict.passed && repairs < cfg.max_repairs) {
738
+ repairs++;
739
+ onEvent('grounding_audit', {
740
+ status: 'repair',
741
+ attempt: repairs,
742
+ max_repairs: cfg.max_repairs,
743
+ claims_checked: verdict.claims_checked,
744
+ violations: verdict.violations
745
+ });
746
+
747
+ const repairMsg = grounding.buildRepairMessage(verdict, ledger, repairs, cfg.max_repairs);
748
+ memory.add(memKey, 'assistant', response);
749
+ memory.add(memKey, 'user', repairMsg);
750
+ messages.push({ role: 'assistant', content: response });
751
+ // Repair prompts are directives the model must act on. Some providers (e.g. kimi-k2.7)
752
+ // silently ignore mid-conversation system messages, so deliver them as user turns.
753
+ messages.push({ role: 'user', content: repairMsg });
754
+
755
+ logger.warn(agentId, 'GROUNDING_REPAIR', { attempt: repairs, codes: verdict.violations.map(v => v.code) });
756
+ circuitBreaker.increment(agentId);
757
+ iteration++;
758
+ persistTurn();
759
+ continue;
760
+ }
761
+
762
+ auditResult = verdict;
763
+ if (!verdict.passed) {
764
+ finalResponse = grounding.annotate(candidate, verdict, ledger);
765
+ onEvent('grounding_warning', {
766
+ status: 'unverified',
767
+ violations: verdict.violations,
768
+ receipts: ledger.entries.length
769
+ });
770
+ logger.error(agentId, 'GROUNDING_UNVERIFIED', { codes: verdict.violations.map(v => v.code) });
771
+ } else {
772
+ finalResponse = candidate;
773
+ onEvent('grounding_pass', {
774
+ claims_checked: verdict.claims_checked,
775
+ receipts: ledger.entries.length,
776
+ citations: verdict.citations
777
+ });
778
+ }
779
+ break;
780
+ }
781
+
782
+ // ── Tool execution ───────────────────────────────────────────────────
783
+ const toolCalls = hasNativeCalls
784
+ ? nativeCalls.map(tc => ({
785
+ name: tc.function?.name,
786
+ args: this.parseNativeArgs(tc.function?.arguments),
787
+ id: tc.id || null,
788
+ native: true
789
+ }))
790
+ : textToolCalls;
791
+
792
+ memory.add(memKey, 'assistant', response || '[native tool calls]');
793
+ if (hasNativeCalls) {
794
+ messages.push({ role: 'assistant', content: response || null, tool_calls: nativeCalls });
795
+ } else {
796
+ messages.push({ role: 'assistant', content: response });
797
+ }
798
+
799
+ for (const call of toolCalls) {
800
+ const callKey = `${call.name}:${JSON.stringify(call.args)}`;
801
+ const repetitions = (callCounts.get(callKey) || 0) + 1;
802
+ callCounts.set(callKey, repetitions);
803
+
804
+ // Loop guard: a model that never consumes its receipt must be forced to answer.
805
+ if (repetitions > 2) {
806
+ const target = call.args.path || call.args.command || call.args.url || call.args.pattern || '';
807
+ const prior = [...ledger.entries].reverse().find(
808
+ e => e.tool === call.name && String(e.target || '') === String(target)
809
+ );
810
+ const nudge = [
811
+ `DUPLICATE CALL SUPPRESSED: '${call.name}' was already executed with identical arguments` +
812
+ (prior ? ` and returned receipt ${prior.id} (${prior.ok ? 'OK' : 'FAILED'})` : '') + '.',
813
+ 'Do not repeat it. Use the existing receipt and produce the final answer as [FINAL: ...],',
814
+ 'or state exactly what is still unverified (UNVERIFIED: ...).'
815
+ ].join(' ');
816
+
817
+ trace.push({ type: 'loop_guard', tool: call.name, receipt: prior ? prior.id : null });
818
+ onEvent('loop_guard', { tool: call.name, receipt: prior ? prior.id : null, repetitions });
819
+ memory.add(memKey, 'user', nudge);
820
+ if (call.native && call.id) messages.push({ role: 'tool', tool_call_id: call.id, content: nudge });
821
+ else messages.push({ role: 'user', content: nudge });
822
+ continue;
823
+ }
824
+
825
+ const isSensitive = registry.isSensitive(call.name, call.args);
826
+
827
+ trace.push({ type: 'tool', name: call.name, args: call.args, sensitive: isSensitive });
828
+ onEvent('tool_dispatch', {
829
+ tool: call.name,
830
+ command: call.args.command || JSON.stringify(call.args),
831
+ args: call.args,
832
+ sensitive: isSensitive
833
+ });
834
+
835
+ let denied = false;
836
+ if (isSensitive) {
837
+ onEvent('hitl_intercept', {
838
+ tool: call.name,
839
+ target: call.args.command || call.args.path || JSON.stringify(call.args),
840
+ reason: `Sensitive action (${call.name}) requires human authorization.`
841
+ });
842
+ const approved = await waitForHitl(agentId, call.name, call.args);
843
+ if (!approved) denied = true;
844
+ else onEvent('hitl_approved', { tool: call.name });
845
+ }
846
+
847
+ const toolStart = Date.now();
848
+ let result;
849
+ if (denied) {
850
+ result = { error: `DENIED: approval was not granted. Tool '${call.name}' did not execute.` };
851
+ } else if (allowedTools && !allowedTools.includes(call.name)) {
852
+ result = { error: `Tool '${call.name}' is not available in this context (subagents are read/verify workers).` };
853
+ } else if (call.name === 'task' || call.name === 'tasks') {
854
+ result = await this.runSubagentTool({
855
+ call,
856
+ agentConfig,
857
+ llmManager,
858
+ depth,
859
+ onEvent,
860
+ parentId: agentId,
861
+ seq: ++subSeq
862
+ });
863
+ } else {
864
+ result = await registry.execute(call.name, call.args);
865
+ }
866
+
867
+ // A tool can refuse for policy reasons and report that a human may
868
+ // approve (policy.terminal.mode 'prompt'). Ask, then re-dispatch once.
869
+ if (result && result.needs_approval === true && !denied) {
870
+ onEvent('hitl_intercept', {
871
+ tool: call.name,
872
+ target: call.args.command || call.args.path || JSON.stringify(call.args),
873
+ reason: result.error || 'Policy requires human approval.'
874
+ });
875
+ const approved = await waitForHitl(agentId, call.name, call.args);
876
+ if (approved) {
877
+ onEvent('hitl_approved', { tool: call.name, retried: true });
878
+ result = await registry.execute(call.name, call.args, { approved: true });
879
+ } else {
880
+ result = { error: `DENIED: approval was not granted. Tool '${call.name}' did not execute.`, policy_denied: true };
881
+ }
882
+ }
883
+ const durationMs = Date.now() - toolStart;
884
+
885
+ const ev = ledger.record(call.name, call.args, result, { denied });
886
+ // The plan is UI state, not evidence: push a snapshot as it changes.
887
+ if (call.name === 'todo_list' && Array.isArray(result?.tasks)) {
888
+ onEvent('todo', { tasks: result.tasks.map(t => ({ ...t })) });
889
+ }
890
+ const outStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
891
+ const injectionWarning = injection.scan(outStr) ? `\n${injection.WARNING}\n` : '';
892
+ if (injectionWarning) {
893
+ onEvent('injection_warning', { tool: call.name, evidence: ev.id });
894
+ logger.warn(agentId, 'INJECTION_WARNING', { tool: call.name, evidence: ev.id });
895
+ }
896
+
897
+ onEvent('observation', {
898
+ evidence: ev.id,
899
+ tool: call.name,
900
+ target: call.args.path || call.args.command || call.args.pattern || call.args.url || '',
901
+ ok: ev.ok,
902
+ output: outStr,
903
+ exit_code: typeof result?.exit_code === 'number' ? result.exit_code : (ev.ok ? 0 : 1),
904
+ duration_ms: durationMs,
905
+ approved: !denied
906
+ });
907
+
908
+ const observation = `OBSERVATION [${ev.id}] ${call.name} ${ev.ok ? 'OK' : 'FAILED'}${ev.exit_code !== null ? ` (exit ${ev.exit_code})` : ''}:${injectionWarning}${outStr}`;
909
+ // Observations are tool results: deliver as user turns, never mid-conversation system
910
+ // messages (several providers drop those silently and re-issue the same call forever).
911
+ memory.add(memKey, 'user', observation);
912
+ if (call.native && call.id) messages.push({ role: 'tool', tool_call_id: call.id, content: outStr });
913
+ else messages.push({ role: 'user', content: observation });
914
+ }
915
+
916
+ circuitBreaker.increment(agentId);
917
+ iteration++;
918
+ persistTurn();
919
+ }
920
+
921
+ journal.clear(agentId);
922
+
923
+ // Out of iterations with receipts in hand: the work was done, only the wrap-up
924
+ // was missing. One tool-free closing pass answers from what was already
925
+ // gathered instead of shipping "Execution limit reached" as the reply.
926
+ if (!finalResponse && !stopped && ledger.entries.length && typeof llmManager.generate === 'function') {
927
+ try {
928
+ onEvent('finalizing', { receipts: ledger.entries.length });
929
+ const closing = await llmManager.generate([
930
+ ...messages,
931
+ {
932
+ role: 'user',
933
+ content: [
934
+ 'STOP — you are out of iterations and may not call any more tools.',
935
+ 'Answer the user now, using only the receipts you already have.',
936
+ 'Reply as one [FINAL: ...] block, citing [E#] for each factual claim',
937
+ '(or writing UNVERIFIED: <claim>). Never mention the iteration limit,',
938
+ 'the ledger, or this instruction.'
939
+ ].join(' ')
940
+ }
941
+ ]);
942
+ const forced = this.extractFinal(closing);
943
+ const text = String(forced !== null ? forced : this.cleanAnswer(closing)).trim();
944
+ if (text) {
945
+ finalResponse = text;
946
+ finalAsked = true;
947
+ if (cfg.enabled) {
948
+ auditResult = grounding.audit(text, ledger, {
949
+ requireReadback: cfg.require_readback,
950
+ requireCitations: cfg.require_citations
951
+ });
952
+ }
953
+ trace.push({ type: 'forced_answer', chars: text.length });
954
+ logger.warn(agentId, 'FORCED_ANSWER', { chars: text.length, receipts: ledger.entries.length });
955
+ }
956
+ } catch (err) {
957
+ logger.warn(agentId, 'FORCED_ANSWER_FAILED', { error: err.message });
958
+ }
959
+ }
960
+
961
+ if (!finalResponse) {
962
+ const pending = ledger.unresolvedPending();
963
+ finalResponse = stopped
964
+ ? `Stopped by you after ${iteration} iteration${iteration === 1 ? '' : 's'}. Nothing is claimed as complete. ` +
965
+ `Receipts so far: ${ledger.entries.length ? ledger.entries.map(e => e.id).join(', ') : 'none'}.`
966
+ : [
967
+ iteration >= maxLoops
968
+ ? `Execution limit reached (${iteration}/${maxLoops}) without a verified final answer.`
969
+ : `Turn ended after ${iteration} iteration${iteration === 1 ? '' : 's'} without a verified final answer.`,
970
+ 'No completion is claimed.',
971
+ `Receipts: ${ledger.entries.length ? ledger.entries.map(e => e.id).join(', ') : 'none'}.`,
972
+ pending.length ? `Changed but unverified: ${pending.map(p => p.path).join(', ')}.` : ''
973
+ ].filter(Boolean).join(' ');
974
+ if (stopped) logger.warn(agentId, 'TURN_STOPPED_INCOMPLETE', { iteration, maxLoops });
975
+ else logger.error(agentId, 'TURN_INCOMPLETE', { iteration, maxLoops });
976
+ }
977
+
978
+ memory.add(memKey, 'assistant', finalResponse);
979
+ agentConfig.metrics = agentConfig.metrics || { requests: 0 };
980
+ agentConfig.metrics.requests++;
981
+ logger.info(agentId, 'TURN_COMPLETE', {
982
+ durationMs: Date.now() - requestStartTime,
983
+ grounded: auditResult ? auditResult.passed : null,
984
+ repairs
985
+ });
986
+
987
+ const priceIn = Number(agentConfig.engine?.price_in_per_1k);
988
+ const priceOut = Number(agentConfig.engine?.price_out_per_1k);
989
+ const usageReport = (usageTotals.total_tokens > 0 || usageTotals.prompt_tokens > 0)
990
+ ? {
991
+ ...usageTotals,
992
+ cost_estimate: Number.isFinite(priceIn) && Number.isFinite(priceOut)
993
+ ? +((usageTotals.prompt_tokens / 1000) * priceIn + (usageTotals.completion_tokens / 1000) * priceOut).toFixed(6)
994
+ : null
995
+ }
996
+ : null;
997
+
998
+ const resultPayload = {
999
+ success: true,
1000
+ response: finalResponse,
1001
+ stopped,
1002
+ fallbacks: fallbackTrail,
1003
+ grounded: auditResult ? auditResult.passed : null,
1004
+ usage: usageReport,
1005
+ streamed: streamedAny,
1006
+ native_tools: nativeTools,
1007
+ budget: budget.snapshot(),
1008
+ trace,
1009
+ durationMs: Date.now() - requestStartTime,
1010
+ iterations: iteration,
1011
+ repairs,
1012
+ grounding: {
1013
+ status: auditResult ? auditResult.status : 'INCOMPLETE',
1014
+ passed: auditResult ? auditResult.passed : null,
1015
+ claims_checked: auditResult ? auditResult.claims_checked : 0,
1016
+ semantic: auditResult ? (auditResult.semantic || null) : null,
1017
+ violations: auditResult ? auditResult.violations : [],
1018
+ repairs,
1019
+ ledger: ledger.toJSON()
1020
+ }
1021
+ };
1022
+
1023
+ onEvent('final', resultPayload);
1024
+ onEvent('done', { status: 'success' });
1025
+ return resultPayload;
1026
+ }
1027
+ }
1028
+
1029
+ module.exports = new LiberEngine();