neuralos 2.9.9 → 2.9.11

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/bin/gybackend.cjs CHANGED
@@ -347146,13 +347146,23 @@ async function manageRecording(args, context2) {
347146
347146
  if (!args.terminalId) {
347147
347147
  out = "start requires terminalId.";
347148
347148
  } else {
347149
- out = `Recording started: ${r.start(args.terminalId, { title: args.title })}`;
347149
+ const ts = context2.terminalService;
347150
+ const id = ts && typeof ts.startRecording === "function" ? ts.startRecording(args.terminalId, { title: args.title }) : r.start(args.terminalId, { title: args.title });
347151
+ out = `Recording started: ${id}`;
347150
347152
  }
347151
347153
  } else if (args.action === "stop") {
347152
347154
  if (!args.recordingId) {
347153
347155
  out = "stop requires recordingId.";
347154
347156
  } else {
347155
347157
  const rec = r.stop(args.recordingId);
347158
+ try {
347159
+ const ts = context2.terminalService;
347160
+ const termId = rec.terminalId ?? args.terminalId;
347161
+ if (termId && ts?.activeRecordings instanceof Map) {
347162
+ if (ts.activeRecordings.get(termId) === args.recordingId) ts.activeRecordings.delete(termId);
347163
+ }
347164
+ } catch {
347165
+ }
347156
347166
  out = `Recording ${args.recordingId} stopped (${rec.events.length} events).`;
347157
347167
  }
347158
347168
  } else if (args.action === "replay") {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "neuralos",
3
- "version": "2.9.9",
4
- "description": "neuralOS — the headless AI-native backend for RTerm. run RTerm-as-a-service (AI agent, SSH/WinRM/Serial/local terminals, fleet orchestration, advanced automation, SRE observability, Netdata, AWS APerf, plugin system, governance/audit, Prometheus/OTel metrics export, secrets vault, on-call paging, AI cost budgets, GitOps, cloud inventory, APM/DEM/Infra/ETW ingestion, AgentSpan/Conductor durable-agent bridge). The RTerm desktop app stays RTerm; neuralOS is the standalone backend daemon. Dual-published as rterm-backend. v2.9.9: AgentSpan/Conductor integration durable, crash-resilient agent execution (agentspan-bridge plugin, 7 plugins total).",
3
+ "version": "2.9.11",
4
+ "description": "neuralOS — the headless AI-native backend for RTerm. run RTerm-as-a-service (AI agent, SSH/WinRM/Serial/local terminals, fleet orchestration, advanced automation, SRE observability, Netdata, AWS APerf, plugin system, governance/audit, Prometheus/OTel metrics export, secrets vault, on-call paging, AI cost budgets, GitOps, cloud inventory, APM/DEM/Infra/ETW ingestion, AgentSpan/Conductor durable-agent bridge). The RTerm desktop app stays RTerm; neuralOS is the standalone backend daemon. Dual-published as rterm-backend. v2.9.11: fixagent-tool session recording now captures (start routed via TerminalService).",
5
5
  "main": "bin/gybackend.cjs",
6
6
  "bin": {
7
7
  "neuralos": "bin/gybackend.cjs",
@@ -63,9 +63,6 @@
63
63
  "apm",
64
64
  "dem",
65
65
  "etw",
66
- "agentspan",
67
- "conductor",
68
- "durable-agents",
69
66
  "monitoring"
70
67
  ]
71
68
  }
@@ -203,11 +203,11 @@ function makeCtx(fetchImpl, settings = {}) {
203
203
  return { tools, triggers, panels, logs, ctx }
204
204
  }
205
205
 
206
- test('register wires 6 tools, 1 trigger, 1 panel', () => {
206
+ test('register wires 9 tools, 1 trigger, 1 panel', () => {
207
207
  const { tools, triggers, panels, ctx } = makeCtx(null, { serverUrl: 'http://x:6767' })
208
208
  register(ctx)
209
- assert.equal(tools.size, 6)
210
- for (const n of ['agentspan_health', 'agentspan_run', 'agentspan_status', 'agentspan_approve', 'agentspan_list', 'agentspan_stop']) assert.ok(tools.has(n), `missing ${n}`)
209
+ assert.equal(tools.size, 9)
210
+ for (const n of ['agentspan_health', 'agentspan_run', 'agentspan_status', 'agentspan_approve', 'agentspan_list', 'agentspan_stop', 'agentspan_export_playbook', 'agentspan_register_playbook', 'agentspan_delegate']) assert.ok(tools.has(n), `missing ${n}`)
211
211
  assert.equal(triggers.length, 1)
212
212
  assert.equal(panels.length, 1)
213
213
  })
@@ -0,0 +1,282 @@
1
+ /**
2
+ * agentspan-bridge.phase2.extreme.spec.ts — exhaustive offline tests for the
3
+ * Phase-2 additions: playbookToWorkflowDef mapper (step→task mapping, DAG
4
+ * edges, wait/rollback, retries), the conductorClient registerWorkflowDef /
5
+ * getWorkflowDef methods, and the new tools (agentspan_export_playbook,
6
+ * agentspan_register_playbook, agentspan_delegate) with mocked fetch. No network.
7
+ */
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { ConductorClient, DEFAULT_BASE_URL } from './conductorClient.mjs'
11
+ import {
12
+ playbookToWorkflowDef,
13
+ stepToTask,
14
+ rollbackToTask,
15
+ taskRef,
16
+ } from './playbookToWorkflowDef.mjs'
17
+ import { register, findPlaybook, buildDelegateAgentConfig } from './index.mjs'
18
+
19
+ // ─── mock fetch ─────────────────────────────────────────────────────────────
20
+ function mockFetch(respond) {
21
+ const calls = []
22
+ const fn = async (url, init) => {
23
+ calls.push({ url, init })
24
+ const r = typeof respond === 'function' ? respond(url, init) : respond
25
+ return { ok: r.ok !== false && (r.status ?? 200) < 400, status: r.status ?? 200, text: async () => r.text ?? (r.json !== undefined ? JSON.stringify(r.json) : '') }
26
+ }
27
+ fn.calls = calls
28
+ return fn
29
+ }
30
+
31
+ const samplePlaybook = {
32
+ id: 'pb-1',
33
+ name: 'nightly backup',
34
+ description: 'backup the core switch',
35
+ steps: [
36
+ { id: 'st-1', name: 'prep', kind: 'command', command: 'term length 0' },
37
+ { id: 'st-2', name: 'collect', kind: 'script', scriptId: 'scr-9' },
38
+ { id: 'st-3', name: 'settle', kind: 'wait', waitSeconds: 5 },
39
+ { id: 'st-4', name: 'apply', kind: 'command', command: 'apply acl', rollback: { kind: 'command', command: 'no acl' }, dependsOn: ['st-1', 'st-2'] },
40
+ ],
41
+ }
42
+
43
+ // ─── taskRef ────────────────────────────────────────────────────────────────
44
+ test('taskRef sanitizes to Conductor-safe refs', () => {
45
+ assert.equal(taskRef('st-1'), 'st_1')
46
+ assert.equal(taskRef('apply acl!'), 'apply_acl_')
47
+ assert.equal(taskRef(undefined, 'fallback'), 'fallback')
48
+ assert.equal(taskRef(''), 'step')
49
+ })
50
+
51
+ // ─── stepToTask ─────────────────────────────────────────────────────────────
52
+ test('command step → HTTP run_command task with command + validate', () => {
53
+ const t = stepToTask({ id: 'st-1', kind: 'command', command: 'show run', validate: { expect: 'ok' } }, 0, {})
54
+ assert.equal(t.type, 'HTTP')
55
+ assert.equal(t.taskReferenceName, 'st_1')
56
+ const req = t.inputParameters.http_request
57
+ assert.equal(req.method, 'POST')
58
+ assert.equal(req.body.kind, 'run_command')
59
+ assert.equal(req.body.command, 'show run')
60
+ assert.deepEqual(req.body.validate, { expect: 'ok' })
61
+ })
62
+
63
+ test('script step → SIMPLE script-reference task (scriptId, no inline body)', () => {
64
+ const t = stepToTask({ id: 'st-2', kind: 'script', scriptId: 'scr-9', name: 'collect' }, 1, {})
65
+ assert.equal(t.type, 'SIMPLE')
66
+ assert.equal(t.inputParameters.kind, 'rterm_script')
67
+ assert.equal(t.inputParameters.scriptId, 'scr-9')
68
+ assert.equal(t.inputParameters.name, 'collect')
69
+ })
70
+
71
+ test('wait step → Conductor WAIT task with duration', () => {
72
+ const t = stepToTask({ id: 'st-3', kind: 'wait', waitSeconds: 7 }, 2, {})
73
+ assert.equal(t.type, 'WAIT')
74
+ assert.equal(t.inputParameters.duration, 7)
75
+ })
76
+
77
+ test('onError=continue sets retryCount; stop (default) is 0', () => {
78
+ const cont = stepToTask({ id: 'a', kind: 'command', command: 'x', onError: 'continue' }, 0, { continueRetryCount: 3 })
79
+ assert.equal(cont.retryCount, 3)
80
+ const stop = stepToTask({ id: 'b', kind: 'command', command: 'x' }, 1, {})
81
+ assert.equal(stop.retryCount, 0)
82
+ })
83
+
84
+ // ─── rollbackToTask ─────────────────────────────────────────────────────────
85
+ test('rollback command → optional compensating HTTP task', () => {
86
+ const t = rollbackToTask({ kind: 'command', command: 'no acl' }, 'st_4', 0)
87
+ assert.equal(t.type, 'HTTP')
88
+ assert.equal(t.optional, true)
89
+ assert.equal(t.inputParameters.http_request.body.compensating, true)
90
+ assert.match(t.taskReferenceName, /^rollback_st_4_/)
91
+ })
92
+
93
+ test('rollback script → optional compensating SIMPLE task', () => {
94
+ const t = rollbackToTask({ kind: 'script', scriptId: 'undo' }, 'st_1', 1)
95
+ assert.equal(t.type, 'SIMPLE')
96
+ assert.equal(t.inputParameters.scriptId, 'undo')
97
+ assert.equal(t.optional, true)
98
+ })
99
+
100
+ // ─── playbookToWorkflowDef: full mapping ────────────────────────────────────
101
+ test('maps all 4 steps + rollback compensating task in order', () => {
102
+ const def = playbookToWorkflowDef(samplePlaybook, { execUri: 'http://gw:17888/rpc/exec' })
103
+ assert.equal(def.name, 'nightly_backup')
104
+ assert.equal(def.version, 1)
105
+ assert.equal(def.schemaVersion, 2)
106
+ assert.equal(def.restartable, true)
107
+ // 4 step tasks + 1 JOIN (st-4 has 2 deps) + 1 rollback = 6
108
+ const types = def.tasks.map((t) => t.type)
109
+ assert.equal(types.filter((x) => x === 'JOIN').length, 1, 'one JOIN for the 2-dep step')
110
+ assert.equal(types.filter((x) => x === 'WAIT').length, 1, 'one WAIT')
111
+ const rollback = def.tasks[def.tasks.length - 1]
112
+ assert.equal(rollback.optional, true, 'last task is the compensating rollback')
113
+ assert.equal(rollback.inputParameters.http_request.body.command, 'no acl')
114
+ })
115
+
116
+ test('JOIN carries the dependsOn edges (fan-in)', () => {
117
+ const def = playbookToWorkflowDef(samplePlaybook, {})
118
+ const join = def.tasks.find((t) => t.type === 'JOIN')
119
+ assert.deepEqual(join.joinOn, ['st_1', 'st_2'])
120
+ // the dependent task (st-4) appears after the JOIN in order
121
+ const joinIdx = def.tasks.indexOf(join)
122
+ const st4 = def.tasks.find((t) => t.taskReferenceName === 'st_4')
123
+ assert.ok(def.tasks.indexOf(st4) > joinIdx, 'dependent task comes after its JOIN')
124
+ })
125
+
126
+ test('linear playbook (no dependsOn) emits no JOINs', () => {
127
+ const pb = { name: 'linear', steps: [
128
+ { id: 'a', kind: 'command', command: '1' },
129
+ { id: 'b', kind: 'command', command: '2' },
130
+ { id: 'c', kind: 'wait', waitSeconds: 1 },
131
+ ] }
132
+ const def = playbookToWorkflowDef(pb, {})
133
+ assert.equal(def.tasks.filter((t) => t.type === 'JOIN').length, 0)
134
+ assert.equal(def.tasks.length, 3)
135
+ })
136
+
137
+ test('multiple rollbacks run in reverse step order (undo newest first)', () => {
138
+ const pb = { name: 'multi', steps: [
139
+ { id: 'a', kind: 'command', command: 'a1', rollback: { kind: 'command', command: 'undo-a' } },
140
+ { id: 'b', kind: 'command', command: 'b1', rollback: { kind: 'command', command: 'undo-b' } },
141
+ ] }
142
+ const def = playbookToWorkflowDef(pb, {})
143
+ const rbs = def.tasks.filter((t) => t.optional)
144
+ assert.equal(rbs.length, 2)
145
+ assert.equal(rbs[0].inputParameters.http_request.body.command, 'undo-b', 'newest rollback first')
146
+ assert.equal(rbs[1].inputParameters.http_request.body.command, 'undo-a')
147
+ })
148
+
149
+ test('execUri flows into the command tasks + inputTemplate', () => {
150
+ const def = playbookToWorkflowDef(samplePlaybook, { execUri: 'http://gw:9000/exec' })
151
+ const cmdTask = def.tasks.find((t) => t.type === 'HTTP')
152
+ assert.equal(cmdTask.inputParameters.http_request.uri, 'http://gw:9000/exec')
153
+ assert.equal(def.inputTemplate.rtermExecUri, 'http://gw:9000/exec')
154
+ })
155
+
156
+ test('rejects a playbook without steps', () => {
157
+ assert.throws(() => playbookToWorkflowDef({ name: 'x' }), /steps array/)
158
+ assert.throws(() => playbookToWorkflowDef(null), /steps array/)
159
+ })
160
+
161
+ // ─── conductorClient: registerWorkflowDef / getWorkflowDef ─────────────────
162
+ test('registerWorkflowDef POSTs an array to /api/metadata/workflow', async () => {
163
+ const f = mockFetch({ json: {} })
164
+ const c = new ConductorClient({ fetchImpl: f })
165
+ const def = playbookToWorkflowDef(samplePlaybook, {})
166
+ await c.registerWorkflowDef(def)
167
+ const call = f.calls[0]
168
+ assert.equal(call.url, `${DEFAULT_BASE_URL}/api/metadata/workflow`)
169
+ assert.equal(call.init.method, 'POST')
170
+ const body = JSON.parse(call.init.body)
171
+ assert.ok(Array.isArray(body), 'body is an array of defs')
172
+ assert.equal(body[0].name, 'nightly_backup')
173
+ })
174
+
175
+ test('registerWorkflowDef accepts an array + rejects empty', async () => {
176
+ const c = new ConductorClient({ fetchImpl: mockFetch({ json: {} }) })
177
+ await assert.rejects(() => c.registerWorkflowDef(), /WorkflowDef/)
178
+ })
179
+
180
+ test('getWorkflowDef GETs by name (+version)', async () => {
181
+ const f = mockFetch({ json: { name: 'x', version: 2 } })
182
+ const c = new ConductorClient({ fetchImpl: f })
183
+ await c.getWorkflowDef('nightly_backup', 2)
184
+ assert.match(f.calls[0].url, /\/api\/metadata\/workflow\/nightly_backup\?version=2$/)
185
+ await assert.rejects(() => c.getWorkflowDef(), /name/)
186
+ })
187
+
188
+ // ─── index helpers: findPlaybook / buildDelegateAgentConfig ────────────────
189
+ test('findPlaybook resolves from AutomationManager then settings, by id or name', () => {
190
+ const pb = { id: 'pb-1', name: 'nightly' }
191
+ const viaAm = findPlaybook({ automationManager: { getPlaybook: (x) => (x === 'pb-1' ? pb : undefined) } }, 'pb-1')
192
+ assert.equal(viaAm.name, 'nightly')
193
+ const viaSettings = findPlaybook({ settings: { automation: { playbooks: [pb] } } }, 'nightly')
194
+ assert.equal(viaSettings.id, 'pb-1')
195
+ assert.equal(findPlaybook({ settings: { automation: { playbooks: [] } } }, 'ghost'), undefined)
196
+ assert.equal(findPlaybook({}, undefined), undefined)
197
+ })
198
+
199
+ test('buildDelegateAgentConfig builds a valid durable AgentConfig', () => {
200
+ const c = buildDelegateAgentConfig('mybot', 'do the thing', { model: 'anthropic/claude-sonnet-4.6' })
201
+ assert.equal(c.name, 'mybot')
202
+ assert.equal(c.model, 'anthropic/claude-sonnet-4.6')
203
+ assert.equal(c.input, 'do the thing')
204
+ const def = buildDelegateAgentConfig(undefined, 't')
205
+ assert.equal(def.name, 'rterm_delegate')
206
+ assert.equal(def.model, 'openai/gpt-4o')
207
+ })
208
+
209
+ // ─── new tools (mocked server) ─────────────────────────────────────────────
210
+ function makeCtx(playbooks, fetchImpl) {
211
+ const tools = new Map()
212
+ const triggers = []
213
+ const panels = []
214
+ const ctx = {
215
+ settings: { agentspan: { serverUrl: DEFAULT_BASE_URL }, automation: { playbooks } },
216
+ registerTool: (t) => tools.set(t.name, t),
217
+ registerTrigger: (t) => triggers.push(t),
218
+ registerPanel: (p) => panels.push(p),
219
+ log: () => {},
220
+ }
221
+ return { tools, ctx, fetchImpl }
222
+ }
223
+
224
+ test('registers 9 tools now (6 phase-1 + 3 phase-2)', () => {
225
+ const { tools, ctx } = makeCtx([], null)
226
+ register(ctx)
227
+ assert.equal(tools.size, 9)
228
+ for (const n of ['agentspan_export_playbook', 'agentspan_register_playbook', 'agentspan_delegate']) assert.ok(tools.has(n), `missing ${n}`)
229
+ })
230
+
231
+ test('agentspan_export_playbook returns the mapped def without registering', async () => {
232
+ const posted = []
233
+ const realFetch = globalThis.fetch
234
+ globalThis.fetch = async (url, init) => { posted.push(url); return { ok: true, status: 200, text: async () => '{}' } }
235
+ const { tools, ctx } = makeCtx([samplePlaybook], null)
236
+ register(ctx)
237
+ const r = await tools.get('agentspan_export_playbook').handler({ playbook: 'nightly backup' })
238
+ assert.equal(r.name, 'nightly_backup')
239
+ assert.ok(r.taskCount >= 5)
240
+ assert.ok(r.def.tasks.some((t) => t.type === 'WAIT'))
241
+ assert.equal(posted.length, 0, 'export is pure — no HTTP calls')
242
+ const missing = await tools.get('agentspan_export_playbook').handler({ playbook: 'ghost' })
243
+ assert.match(missing.error, /not found/)
244
+ globalThis.fetch = realFetch
245
+ })
246
+
247
+ test('agentspan_register_playbook registers the def on the server', async () => {
248
+ const calls = []
249
+ const realFetch = globalThis.fetch
250
+ globalThis.fetch = async (url, init) => {
251
+ calls.push({ url, method: init.method })
252
+ return { ok: true, status: 200, text: async () => '{}' }
253
+ }
254
+ const { tools, ctx } = makeCtx([samplePlaybook], null)
255
+ register(ctx)
256
+ const r = await tools.get('agentspan_register_playbook').handler({ playbook: 'nightly backup' })
257
+ assert.equal(r.registered, true)
258
+ assert.equal(r.name, 'nightly_backup')
259
+ assert.equal(r.runWith.args.workflow, 'nightly_backup')
260
+ assert.ok(calls.some((c) => c.url.endsWith('/api/metadata/workflow') && c.method === 'POST'))
261
+ globalThis.fetch = realFetch
262
+ })
263
+
264
+ test('agentspan_delegate builds an AgentConfig and returns executionId + followUp', async () => {
265
+ const realFetch = globalThis.fetch
266
+ let postedBody
267
+ globalThis.fetch = async (url, init) => {
268
+ if (init.method === 'POST') postedBody = JSON.parse(init.body)
269
+ return { ok: true, status: 200, text: async () => JSON.stringify({ executionId: 'exec-del-1' }) }
270
+ }
271
+ const { tools, ctx } = makeCtx([], null)
272
+ register(ctx)
273
+ const bad = await tools.get('agentspan_delegate').handler({})
274
+ assert.match(bad.error, /prompt/)
275
+ const r = await tools.get('agentspan_delegate').handler({ prompt: 'investigate the disk-full on web-01', model: 'openai/gpt-5.6-sol' })
276
+ assert.equal(r.delegated, true)
277
+ assert.equal(r.executionId, 'exec-del-1')
278
+ assert.match(r.uiUrl, /\/execution\/exec-del-1$/)
279
+ assert.equal(postedBody.model, 'openai/gpt-5.6-sol')
280
+ assert.equal(postedBody.input, 'investigate the disk-full on web-01')
281
+ globalThis.fetch = realFetch
282
+ })
@@ -161,6 +161,22 @@ export class ConductorClient {
161
161
  const q = new URLSearchParams({ freeText: query, size: String(size), sort: 'startTime:DESC' })
162
162
  return this.request('GET', `/api/workflow/search?${q}`)
163
163
  }
164
+
165
+ // ── Metadata (WorkflowDef registration) ──────────────────────────────────
166
+ /** Register (create/update) a WorkflowDef on the server (POST /api/metadata/workflow).
167
+ * Accepts a single def or an array (Conductor accepts a JSON array of defs). */
168
+ async registerWorkflowDef(defOrDefs) {
169
+ if (!defOrDefs) throw new Error('registerWorkflowDef needs a WorkflowDef')
170
+ const body = Array.isArray(defOrDefs) ? defOrDefs : [defOrDefs]
171
+ return this.request('POST', '/api/metadata/workflow', body)
172
+ }
173
+
174
+ /** Get a registered WorkflowDef by name (+ optional version). */
175
+ async getWorkflowDef(name, version) {
176
+ if (!name) throw new Error('getWorkflowDef needs a name')
177
+ const q = version !== undefined ? `?version=${version}` : ''
178
+ return this.request('GET', `/api/metadata/workflow/${encodeURIComponent(name)}${q}`)
179
+ }
164
180
  }
165
181
 
166
182
  export default ConductorClient
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { ConductorClient, DEFAULT_BASE_URL, joinUrl } from './conductorClient.mjs'
24
+ import { playbookToWorkflowDef } from './playbookToWorkflowDef.mjs'
24
25
 
25
26
  // ─── config resolution ──────────────────────────────────────────────────────
26
27
 
@@ -122,6 +123,48 @@ async function guarded(fn, log) {
122
123
  }
123
124
  }
124
125
 
126
+ // ─── playbook resolution (from RTerm AutomationManager / settings) ─────────
127
+
128
+ /** Find a named RTerm playbook from the plugin context (AutomationManager or
129
+ * settings.automation.playbooks), by id or name. */
130
+ export function findPlaybook(ctx = {}, nameOrId) {
131
+ if (!nameOrId) return undefined
132
+ const want = String(nameOrId)
133
+ // 1) AutomationManager (runtime handle)
134
+ try {
135
+ const am = ctx.automationManager
136
+ if (am) {
137
+ if (typeof am.getPlaybook === 'function') {
138
+ const p = am.getPlaybook(want)
139
+ if (p) return p
140
+ }
141
+ if (typeof am.listPlaybooks === 'function') {
142
+ const found = (am.listPlaybooks() || []).find((x) => x.id === want || x.name === want)
143
+ if (found) return found
144
+ }
145
+ }
146
+ } catch { /* fall through to settings */ }
147
+ // 2) settings.automation.playbooks
148
+ try {
149
+ const s = (typeof ctx.getSettings === 'function' ? ctx.getSettings() : ctx.settings) || {}
150
+ const list = s.automation?.playbooks || []
151
+ return list.find((x) => x.id === want || x.name === want)
152
+ } catch {
153
+ return undefined
154
+ }
155
+ }
156
+
157
+ /** Build a default durable AgentConfig for `agentspan_delegate` (a simple
158
+ * tool-using ReAct agent compiled + started on the server). */
159
+ export function buildDelegateAgentConfig(name, prompt, opts = {}) {
160
+ return {
161
+ name: name || 'rterm_delegate',
162
+ model: opts.model || 'openai/gpt-4o',
163
+ instructions: opts.instructions || 'You are a durable RTerm delegate agent. Complete the task, calling tools as needed.',
164
+ input: prompt,
165
+ }
166
+ }
167
+
125
168
  // ─── plugin entry ───────────────────────────────────────────────────────────
126
169
 
127
170
  export function register(ctx) {
@@ -238,6 +281,75 @@ export function register(ctx) {
238
281
  }, log),
239
282
  })
240
283
 
284
+ // Tool: agentspan_export_playbook — map a named RTerm playbook to a Conductor
285
+ // WorkflowDef (returns the def; does NOT register it). Pure/dry-run preview.
286
+ registerTool({
287
+ name: 'agentspan_export_playbook',
288
+ description: 'Map an RTerm playbook (by id or name) to a Conductor WorkflowDef and return it — a dry-run preview of what agentspan_register_playbook would register. command steps → HTTP run_command tasks, script steps → script-reference tasks, wait steps → WAIT tasks; sequential + dependsOn DAG + retries + rollback compensating tasks.',
289
+ params: {
290
+ playbook: { type: 'string', description: 'RTerm playbook id or name' },
291
+ execUri: { type: 'string', description: 'Override the RTerm exec URI tasks call (default from settings)', optional: true },
292
+ },
293
+ handler: async (p) => guarded(async () => {
294
+ const pb = findPlaybook(ctx, p?.playbook)
295
+ if (!pb) return { error: `playbook not found: ${p?.playbook ?? '(none given)'}` }
296
+ const execUri = p?.execUri || `${config.gatewayUrl ?? 'http://localhost:17888'}/rpc/exec`
297
+ const def = playbookToWorkflowDef(pb, { execUri })
298
+ return { name: def.name, taskCount: def.tasks.length, def }
299
+ }, log),
300
+ })
301
+
302
+ // Tool: agentspan_register_playbook — register an RTerm playbook on the
303
+ // AgentSpan server as a Conductor WorkflowDef (so agentspan_run {workflow:<name>}
304
+ // and AgentSpan SUB_WORKFLOW steps can invoke it).
305
+ registerTool({
306
+ name: 'agentspan_register_playbook',
307
+ description: 'Export an RTerm playbook to a Conductor WorkflowDef AND register it on the AgentSpan server (POST /api/metadata/workflow), so it can be run durably via agentspan_run {workflow:<name>} or invoked by AgentSpan agents as a SUB_WORKFLOW step.',
308
+ params: {
309
+ playbook: { type: 'string', description: 'RTerm playbook id or name' },
310
+ execUri: { type: 'string', description: 'Override the RTerm exec URI tasks call (default from settings)', optional: true },
311
+ },
312
+ handler: async (p) => guarded(async () => {
313
+ const pb = findPlaybook(ctx, p?.playbook)
314
+ if (!pb) return { error: `playbook not found: ${p?.playbook ?? '(none given)'}` }
315
+ const execUri = p?.execUri || `${config.gatewayUrl ?? 'http://localhost:17888'}/rpc/exec`
316
+ const def = playbookToWorkflowDef(pb, { execUri })
317
+ await client.registerWorkflowDef(def)
318
+ return {
319
+ registered: true,
320
+ name: def.name,
321
+ taskCount: def.tasks.length,
322
+ runWith: { tool: 'agentspan_run', args: { workflow: def.name } },
323
+ uiUrl: joinUrl(config.serverUrl, `/workflowDef/${def.name}`),
324
+ }
325
+ }, log),
326
+ })
327
+
328
+ // Tool: agentspan_delegate — run a prompt/task as a durable AgentSpan agent
329
+ // (AgentConfig), returning an executionId that survives RTerm/host restart.
330
+ registerTool({
331
+ name: 'agentspan_delegate',
332
+ description: 'Delegate a long-running task to AgentSpan: runs the prompt as a durable agent on the Conductor server (compiles + starts an AgentConfig) and returns an executionId that survives restarts. Follow up with agentspan_status / agentspan_approve / agentspan_stop.',
333
+ params: {
334
+ prompt: { type: 'string', description: 'The task/prompt for the durable agent' },
335
+ name: { type: 'string', description: 'Agent name (default rterm_delegate)', optional: true },
336
+ model: { type: 'string', description: 'Model id (default openai/gpt-4o)', optional: true },
337
+ instructions: { type: 'string', description: 'Override system instructions', optional: true },
338
+ },
339
+ handler: async (p) => guarded(async () => {
340
+ if (!p?.prompt) return { error: 'agentspan_delegate needs a prompt' }
341
+ const cfg = buildDelegateAgentConfig(p.name, p.prompt, { model: p.model, instructions: p.instructions })
342
+ const r = await client.runAgent(cfg)
343
+ return {
344
+ delegated: true,
345
+ executionId: r.executionId,
346
+ serverUrl: config.serverUrl,
347
+ uiUrl: joinUrl(config.serverUrl, `/execution/${r.executionId}`),
348
+ followUp: { status: 'agentspan_status', approve: 'agentspan_approve', stop: 'agentspan_stop' },
349
+ }
350
+ }, log),
351
+ })
352
+
241
353
  // Trigger: agentspan_execution_failed — fires when a durable execution fails.
242
354
  registerTrigger({
243
355
  name: 'agentspan_execution_failed',
@@ -272,4 +384,6 @@ export default {
272
384
  summarizeStatus,
273
385
  toExecutionRows,
274
386
  isFailedExecution,
387
+ findPlaybook,
388
+ buildDelegateAgentConfig,
275
389
  }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * playbookToWorkflowDef.mjs — map an RTerm playbook (PlaybookEntry) to a
3
+ * Netflix-Conductor WorkflowDef so AgentSpan agents can invoke it as a step
4
+ * (SUB_WORKFLOW) or RTerm can register + run it durably on the Conductor server.
5
+ *
6
+ * Pure mapping (no I/O):
7
+ * - command step → a run_command-style HTTP task carrying the command + target
8
+ * - script step → an HTTP task that references the saved script id (the
9
+ * Conductor worker resolves the script body by id)
10
+ * - wait step → a Conductor WAIT task (durationSeconds)
11
+ * - sequential + dependsOn DAG ordering → Conductor task `taskReferenceName`
12
+ * graph with JOIN on multiple dependencies
13
+ * - retries (onError=continue → retry, else fail-fast) → Conductor `retryCount`
14
+ * - rollback steps → compensating tasks appended after the main flow
15
+ *
16
+ * The emitted WorkflowDef is valid for `POST /api/metadata/workflow` on any
17
+ * Conductor OSS / AgentSpan server.
18
+ */
19
+
20
+ /** Sanitize a string into a Conductor-safe taskReferenceName ([A-Za-z0-9_]). */
21
+ export function taskRef(id, fallback) {
22
+ const s = String(id ?? fallback ?? 'step').replace(/[^A-Za-z0-9_]/g, '_')
23
+ return s.length > 0 ? s : 'step'
24
+ }
25
+
26
+ /** Map a single RTerm playbook step to a Conductor task (no edges yet). */
27
+ export function stepToTask(step, index, opts = {}) {
28
+ const ref = taskRef(step.id ?? `step_${index}`)
29
+ const name = step.name || ref
30
+ const base = {
31
+ name,
32
+ taskReferenceName: ref,
33
+ retryCount: step.onError === 'continue' ? (opts.continueRetryCount ?? 0) : 0,
34
+ startDelay: 0,
35
+ optional: false,
36
+ asyncComplete: false,
37
+ }
38
+
39
+ if (step.kind === 'wait') {
40
+ return {
41
+ ...base,
42
+ type: 'WAIT',
43
+ inputParameters: { duration: step.waitSeconds ?? 0 },
44
+ }
45
+ }
46
+
47
+ if (step.kind === 'script') {
48
+ // Reference the saved script by id; the Conductor-side worker resolves the
49
+ // script body (kept out of the def so it stays compact + secret-free).
50
+ return {
51
+ ...base,
52
+ type: 'SIMPLE',
53
+ inputParameters: {
54
+ kind: 'rterm_script',
55
+ scriptId: step.scriptId,
56
+ name,
57
+ },
58
+ }
59
+ }
60
+
61
+ // kind === 'command' (default): an HTTP task that invokes RTerm's command
62
+ // execution surface with the command + target scope.
63
+ return {
64
+ ...base,
65
+ type: 'HTTP',
66
+ inputParameters: {
67
+ http_request: {
68
+ uri: opts.execUri ?? '${workflow.input.rtermExecUri}',
69
+ method: 'POST',
70
+ headers: { 'content-type': 'application/json' },
71
+ body: {
72
+ kind: 'run_command',
73
+ command: step.command ?? '',
74
+ ...(step.validate ? { validate: step.validate } : {}),
75
+ },
76
+ connectionTimeOut: opts.connectionTimeOut ?? 5000,
77
+ readTimeOut: opts.readTimeOut ?? 300000,
78
+ },
79
+ },
80
+ }
81
+ }
82
+
83
+ /** Map a rollback action to a compensating Conductor task. */
84
+ export function rollbackToTask(rollback, stepRef, index) {
85
+ const ref = taskRef(`rollback_${stepRef}_${index}`)
86
+ if (rollback.kind === 'script') {
87
+ return {
88
+ name: ref,
89
+ taskReferenceName: ref,
90
+ type: 'SIMPLE',
91
+ inputParameters: { kind: 'rterm_script', scriptId: rollback.scriptId, compensating: true },
92
+ retryCount: 0,
93
+ optional: true, // a failing rollback never blocks the workflow result
94
+ asyncComplete: false,
95
+ }
96
+ }
97
+ return {
98
+ name: ref,
99
+ taskReferenceName: ref,
100
+ type: 'HTTP',
101
+ inputParameters: {
102
+ http_request: {
103
+ uri: '${workflow.input.rtermExecUri}',
104
+ method: 'POST',
105
+ headers: { 'content-type': 'application/json' },
106
+ body: { kind: 'run_command', command: rollback.command ?? '', compensating: true },
107
+ },
108
+ },
109
+ retryCount: 0,
110
+ optional: true,
111
+ asyncComplete: false,
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Build the ordered task list with DAG edges from dependsOn.
117
+ * Returns { tasks, order } where order is the topological order of step refs.
118
+ * Steps with no dependsOn depend on the previous step (linear chain). Steps
119
+ * with multiple dependsOn get a JOIN task.
120
+ */
121
+ export function buildDag(steps) {
122
+ const refs = steps.map((s, i) => taskRef(s.id ?? `step_${i}`))
123
+ const tasks = []
124
+ const joins = []
125
+ steps.forEach((s, i) => {
126
+ const ref = refs[i]
127
+ const deps = Array.isArray(s.dependsOn) && s.dependsOn.length > 0
128
+ ? s.dependsOn.map((d) => taskRef(d))
129
+ : (i === 0 ? [] : [refs[i - 1]])
130
+ if (deps.length > 1) {
131
+ const joinRef = taskRef(`join_${ref}`)
132
+ joins.push({ ref: joinRef, type: 'JOIN', joinOn: deps, taskReferenceName: joinRef, name: joinRef })
133
+ }
134
+ })
135
+ return { refs, joins }
136
+ }
137
+
138
+ /**
139
+ * Map a full RTerm playbook to a Conductor WorkflowDef.
140
+ * @param {object} playbook PlaybookEntry
141
+ * @param {object} [opts] { execUri, connectionTimeOut, readTimeOut, continueRetryCount }
142
+ * @returns {object} a Conductor WorkflowDef (POST /api/metadata/workflow)
143
+ */
144
+ export function playbookToWorkflowDef(playbook, opts = {}) {
145
+ if (!playbook || !Array.isArray(playbook.steps)) {
146
+ throw new Error('playbookToWorkflowDef needs a playbook with a steps array')
147
+ }
148
+ const name = String(playbook.name || playbook.id || 'rterm_playbook').replace(/\s+/g, '_')
149
+ const steps = playbook.steps
150
+ const tasks = []
151
+ const rollbackTasks = []
152
+
153
+ // Main flow: map each step, then wire sequential/DAG ordering by emitting
154
+ // tasks in dependency order (Conductor executes top-down; dependsOn edges are
155
+ // expressed via JOINs for fan-in).
156
+ const refs = steps.map((s, i) => taskRef(s.id ?? `step_${i}`))
157
+ const emittedJoins = new Set()
158
+
159
+ steps.forEach((step, i) => {
160
+ const ref = refs[i]
161
+ const task = stepToTask(step, i, opts)
162
+ // Wire dependency: Conductor's default is sequential task order, but we
163
+ // explicitly model dependsOn fan-in as a JOIN before this task when the
164
+ // step has multiple dependencies.
165
+ const deps = Array.isArray(step.dependsOn) && step.dependsOn.length > 0
166
+ ? step.dependsOn.map((d) => taskRef(d))
167
+ : []
168
+ if (deps.length > 1 && !emittedJoins.has(ref)) {
169
+ const joinRef = taskRef(`join_${ref}`)
170
+ tasks.push({
171
+ name: joinRef,
172
+ taskReferenceName: joinRef,
173
+ type: 'JOIN',
174
+ joinOn: deps,
175
+ })
176
+ emittedJoins.add(ref)
177
+ }
178
+ tasks.push(task)
179
+ if (step.rollback) {
180
+ rollbackTasks.push(rollbackToTask(step.rollback, ref, rollbackTasks.length))
181
+ }
182
+ })
183
+
184
+ // Compensating (rollback) tasks run after the main flow, in reverse step
185
+ // order (undo the most recent change first) — marked optional so they never
186
+ // mask the workflow's real result.
187
+ const orderedRollback = [...rollbackTasks].reverse()
188
+
189
+ return {
190
+ name,
191
+ description: playbook.description || `RTerm playbook: ${playbook.name ?? name}`,
192
+ version: 1,
193
+ tasks: [...tasks, ...orderedRollback],
194
+ inputParameters: [],
195
+ outputParameters: {},
196
+ schemaVersion: 2,
197
+ restartable: true,
198
+ workflowStatusListenerEnabled: false,
199
+ ownerEmail: 'rterm@hyperspace.ng',
200
+ timeoutPolicy: 'ALERT_ONLY',
201
+ timeoutSeconds: 0,
202
+ variables: {},
203
+ inputTemplate: {
204
+ rtermExecUri: opts.execUri ?? 'http://localhost:17888/rpc/exec',
205
+ },
206
+ }
207
+ }
208
+
209
+ export default { playbookToWorkflowDef, stepToTask, rollbackToTask, taskRef, buildDag }
@@ -4,12 +4,15 @@
4
4
  "description": "AgentSpan/Conductor bridge for RTerm — run durable, crash-resilient agents and workflows on an AgentSpan (Conductor) server from RTerm. Adds durable agent execution (resume-from-step on crash), plan-execute determinism, enterprise event triggers, and a live execution feed to RTerm's agent + dashboard. Server URL + optional auth are configured in Settings (agentspan block); secrets via the vault.",
5
5
  "entry": "index.mjs",
6
6
  "tools": [
7
+ "agentspan_health",
7
8
  "agentspan_run",
8
9
  "agentspan_status",
9
10
  "agentspan_approve",
10
11
  "agentspan_list",
11
12
  "agentspan_stop",
12
- "agentspan_health"
13
+ "agentspan_export_playbook",
14
+ "agentspan_register_playbook",
15
+ "agentspan_delegate"
13
16
  ],
14
17
  "triggers": [
15
18
  "agentspan_execution_failed"