@tagma/sdk 0.7.0 → 0.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tagma/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Local AI task orchestration engine — core SDK for tagma pipelines",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -92,7 +92,7 @@
92
92
  "release:publish": "bun scripts/release.ts --publish"
93
93
  },
94
94
  "dependencies": {
95
- "@tagma/types": "0.4.8",
95
+ "@tagma/types": "0.4.9",
96
96
  "js-yaml": "^4.1.0",
97
97
  "chokidar": "^4.0.0"
98
98
  }
@@ -36,7 +36,7 @@ function result(stdout: string, normalizedOutput: string | null = null): TaskRes
36
36
  }
37
37
 
38
38
  describe('inferEffectivePorts', () => {
39
- test('returns declared ports for command tasks', () => {
39
+ test('returns typed outputs for command tasks', () => {
40
40
  const config: PipelineConfig = {
41
41
  name: 'p',
42
42
  tracks: [
@@ -48,7 +48,7 @@ describe('inferEffectivePorts', () => {
48
48
  id: 'cmd',
49
49
  name: 'Cmd',
50
50
  command: 'echo',
51
- ports: { outputs: [{ name: 'city', type: 'string' }] },
51
+ outputs: { city: { type: 'string' } },
52
52
  },
53
53
  ],
54
54
  },
@@ -75,7 +75,7 @@ describe('inferEffectivePorts', () => {
75
75
  id: 'up',
76
76
  name: 'Up',
77
77
  command: 'echo',
78
- ports: { outputs: [{ name: 'city', type: 'string' }] },
78
+ outputs: { city: { type: 'string' } },
79
79
  },
80
80
  { id: 'prompt', name: 'Prompt', prompt: 'hi', depends_on: ['up'] },
81
81
  ],
@@ -103,13 +103,13 @@ describe('inferEffectivePorts', () => {
103
103
  id: 'a',
104
104
  name: 'A',
105
105
  command: 'echo',
106
- ports: { outputs: [{ name: 'city', type: 'string' }] },
106
+ outputs: { city: { type: 'string' } },
107
107
  },
108
108
  {
109
109
  id: 'b',
110
110
  name: 'B',
111
111
  command: 'echo',
112
- ports: { outputs: [{ name: 'city', type: 'string' }] },
112
+ outputs: { city: { type: 'string' } },
113
113
  },
114
114
  {
115
115
  id: 'prompt',
@@ -131,7 +131,7 @@ describe('inferEffectivePorts', () => {
131
131
  });
132
132
 
133
133
  describe('extractSuccessfulOutputs', () => {
134
- test('combines lightweight binding outputs with typed port outputs', () => {
134
+ test('extracts typed binding outputs', () => {
135
135
  const config: PipelineConfig = {
136
136
  name: 'p',
137
137
  tracks: [
@@ -143,8 +143,7 @@ describe('extractSuccessfulOutputs', () => {
143
143
  id: 'cmd',
144
144
  name: 'Cmd',
145
145
  command: 'echo',
146
- outputs: { raw: { from: 'stdout' } },
147
- ports: { outputs: [{ name: 'city', type: 'string' }] },
146
+ outputs: { city: { type: 'string' }, raw: { from: 'stdout' } },
148
147
  },
149
148
  ],
150
149
  },
@@ -154,7 +153,7 @@ describe('extractSuccessfulOutputs', () => {
154
153
  const node = ctx.dag.nodes.get('t.cmd')!;
155
154
  const extracted = extractSuccessfulOutputs({
156
155
  task: node.task,
157
- effectivePorts: node.task.ports,
156
+ effectivePorts: undefined,
158
157
  result: result('{"city":"Paris"}'),
159
158
  });
160
159
  expect(extracted.outputs).toEqual({
@@ -1,4 +1,11 @@
1
- import type { TaskConfig, TaskPorts, TaskResult } from '../types';
1
+ import type {
2
+ PortDef,
3
+ TaskConfig,
4
+ TaskInputBindings,
5
+ TaskOutputBindings,
6
+ TaskPorts,
7
+ TaskResult,
8
+ } from '../types';
2
9
  import {
3
10
  extractTaskBindingOutputs,
4
11
  extractTaskOutputs,
@@ -18,6 +25,40 @@ function isCommandTaskConfig(
18
25
  return task.command !== undefined && task.prompt === undefined;
19
26
  }
20
27
 
28
+ function inputBindingsToPorts(bindings: TaskInputBindings | undefined): PortDef[] | undefined {
29
+ if (!bindings || Object.keys(bindings).length === 0) return undefined;
30
+ return Object.entries(bindings).map(([name, binding]) => ({
31
+ name,
32
+ type: binding.type ?? 'json',
33
+ ...(binding.description ? { description: binding.description } : {}),
34
+ ...(binding.required !== undefined ? { required: binding.required } : {}),
35
+ ...(binding.default !== undefined ? { default: binding.default } : {}),
36
+ ...(binding.enum ? { enum: [...binding.enum] } : {}),
37
+ ...(binding.from ? { from: binding.from } : {}),
38
+ }));
39
+ }
40
+
41
+ function outputBindingsToPorts(bindings: TaskOutputBindings | undefined): PortDef[] | undefined {
42
+ if (!bindings || Object.keys(bindings).length === 0) return undefined;
43
+ return Object.entries(bindings).map(([name, binding]) => ({
44
+ name,
45
+ type: binding.type ?? 'json',
46
+ ...(binding.description ? { description: binding.description } : {}),
47
+ ...(binding.default !== undefined ? { default: binding.default } : {}),
48
+ ...(binding.enum ? { enum: [...binding.enum] } : {}),
49
+ }));
50
+ }
51
+
52
+ function taskBindingsAsPorts(task: TaskConfig): TaskPorts | undefined {
53
+ const inputs = inputBindingsToPorts(task.inputs);
54
+ const outputs = outputBindingsToPorts(task.outputs);
55
+ if (!inputs && !outputs) return undefined;
56
+ return {
57
+ ...(inputs ? { inputs } : {}),
58
+ ...(outputs ? { outputs } : {}),
59
+ };
60
+ }
61
+
21
62
  export type EffectivePortsResult =
22
63
  | {
23
64
  readonly kind: 'ready';
@@ -38,7 +79,7 @@ export function inferEffectivePorts(
38
79
  const isPromptTask = isPromptTaskConfig(task);
39
80
 
40
81
  if (!isPromptTask) {
41
- return { kind: 'ready', isPromptTask: false, effectivePorts: task.ports };
82
+ return { kind: 'ready', isPromptTask: false, effectivePorts: taskBindingsAsPorts(task) };
42
83
  }
43
84
 
44
85
  const inference = inferPromptPorts({
@@ -47,7 +88,7 @@ export function inferEffectivePorts(
47
88
  const isUpstreamCommand = upstream ? isCommandTaskConfig(upstream.task) : false;
48
89
  return {
49
90
  taskId: upstreamId,
50
- outputs: isUpstreamCommand ? upstream?.task.ports?.outputs : undefined,
91
+ outputs: isUpstreamCommand ? outputBindingsToPorts(upstream?.task.outputs) : undefined,
51
92
  };
52
93
  }),
53
94
  downstreams: (ctx.directDownstreams.get(taskId) ?? []).map((downstreamId) => {
@@ -55,7 +96,7 @@ export function inferEffectivePorts(
55
96
  const isDownstreamCommand = downstream ? isCommandTaskConfig(downstream.task) : false;
56
97
  return {
57
98
  taskId: downstreamId,
58
- inputs: isDownstreamCommand ? downstream?.task.ports?.inputs : undefined,
99
+ inputs: isDownstreamCommand ? inputBindingsToPorts(downstream?.task.inputs) : undefined,
59
100
  };
60
101
  }),
61
102
  });
@@ -98,21 +139,23 @@ export function extractSuccessfulOutputs(
98
139
  extractedOutputs = bindingExtraction.outputs;
99
140
  }
100
141
 
101
- const portExtraction = extractTaskOutputs(
102
- effectivePorts,
103
- result.stdout,
104
- result.normalizedOutput,
105
- );
106
- if (effectivePorts?.outputs && effectivePorts.outputs.length > 0) {
107
- extractedOutputs = {
108
- ...(extractedOutputs ?? {}),
109
- ...portExtraction.outputs,
142
+ if ((!task.outputs || Object.keys(task.outputs).length === 0) && effectivePorts?.outputs?.length) {
143
+ const portExtraction = extractTaskOutputs(
144
+ effectivePorts,
145
+ result.stdout,
146
+ result.normalizedOutput,
147
+ );
148
+ extractedOutputs = portExtraction.outputs;
149
+ return {
150
+ outputs: extractedOutputs,
151
+ bindingDiagnostic: bindingExtraction.diagnostic,
152
+ portDiagnostic: portExtraction.diagnostic,
110
153
  };
111
154
  }
112
155
 
113
156
  return {
114
157
  outputs: extractedOutputs,
115
158
  bindingDiagnostic: bindingExtraction.diagnostic,
116
- portDiagnostic: portExtraction.diagnostic,
159
+ portDiagnostic: null,
117
160
  };
118
161
  }
@@ -21,11 +21,7 @@ import {
21
21
  renderInputsBlock,
22
22
  renderOutputSchemaBlock,
23
23
  } from '../prompt-doc';
24
- import {
25
- resolveTaskBindingInputs,
26
- resolveTaskInputs,
27
- substituteInputs,
28
- } from '../ports';
24
+ import { resolveTaskBindingInputs, resolveTaskInputs, substituteInputs } from '../ports';
29
25
  import { executeHook, buildTaskContext } from '../hooks';
30
26
  import { clip, tailLines, type Logger } from '../logger';
31
27
  import type { ApprovalGateway } from '../approval';
@@ -35,15 +31,17 @@ import { TriggerBlockedError, TriggerTimeoutError } from './trigger-errors';
35
31
 
36
32
  const MAX_NORMALIZED_BYTES = 1_000_000;
37
33
 
38
- function isPromptTaskConfig(
39
- task: { readonly prompt?: string; readonly command?: string },
40
- ): task is { readonly prompt: string; readonly command?: undefined } {
34
+ function isPromptTaskConfig(task: {
35
+ readonly prompt?: string;
36
+ readonly command?: string;
37
+ }): task is { readonly prompt: string; readonly command?: undefined } {
41
38
  return task.prompt !== undefined && task.command === undefined;
42
39
  }
43
40
 
44
- function isCommandTaskConfig(
45
- task: { readonly command?: string; readonly prompt?: string },
46
- ): task is { readonly command: string; readonly prompt?: undefined } {
41
+ function isCommandTaskConfig(task: {
42
+ readonly command?: string;
43
+ readonly prompt?: string;
44
+ }): task is { readonly command: string; readonly prompt?: undefined } {
47
45
  return task.command !== undefined && task.prompt === undefined;
48
46
  }
49
47
 
@@ -196,7 +194,12 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
196
194
  const hookResult = await executeHook(
197
195
  config.hooks,
198
196
  'task_start',
199
- buildTaskContext('task_start', pipelineInfo, ctx.trackInfoOf(taskId), ctx.buildTaskInfoObj(taskId)),
197
+ buildTaskContext(
198
+ 'task_start',
199
+ pipelineInfo,
200
+ ctx.trackInfoOf(taskId),
201
+ ctx.buildTaskInfoObj(taskId),
202
+ ),
200
203
  workDir,
201
204
  ctx.abortController.signal,
202
205
  );
@@ -309,50 +312,48 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
309
312
  );
310
313
  }
311
314
 
312
- // Feed effective ports into `resolveTaskInputs` by shallow-cloning
313
- // the task. Prompt tasks get the inferred ports; Command tasks are
314
- // unchanged (effectivePorts === task.ports).
315
- const taskForResolve: TaskConfig =
316
- effectivePorts === task.ports ? task : { ...task, ports: effectivePorts };
317
- const inputResolution = resolveTaskInputs(taskForResolve, ctx.outputValuesMap, node.dependsOn);
318
- if (inputResolution.kind === 'blocked') {
319
- log.error(
320
- `[task:${taskId}]`,
321
- `blocked — cannot resolve port inputs:\n${inputResolution.reason}`,
315
+ let inferredPromptInputs: Readonly<Record<string, unknown>> = {};
316
+ if (isPromptTask && effectivePorts?.inputs && effectivePorts.inputs.length > 0) {
317
+ const inputResolution = resolveTaskInputs(
318
+ { ...task, ports: effectivePorts },
319
+ ctx.outputValuesMap,
320
+ node.dependsOn,
322
321
  );
323
- state.result = {
324
- exitCode: -1,
325
- stdout: '',
326
- stderr: `[engine] port input resolution failed:\n${inputResolution.reason}`,
327
- stdoutPath: null,
328
- stderrPath: null,
329
- durationMs: 0,
330
- sessionId: null,
331
- normalizedOutput: null,
332
- failureKind: 'spawn_error',
333
- outputs: null,
334
- };
335
- state.finishedAt = nowISO();
336
- ctx.setTaskStatus(taskId, 'blocked');
337
- try {
338
- await ctx.fireHook(taskId, 'task_failure');
339
- } catch (hookErr) {
322
+ if (inputResolution.kind === 'blocked') {
340
323
  log.error(
341
324
  `[task:${taskId}]`,
342
- `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
325
+ `blocked cannot resolve inferred prompt inputs:\n${inputResolution.reason}`,
343
326
  );
327
+ state.result = {
328
+ exitCode: -1,
329
+ stdout: '',
330
+ stderr: `[engine] inferred prompt input resolution failed:\n${inputResolution.reason}`,
331
+ stdoutPath: null,
332
+ stderrPath: null,
333
+ durationMs: 0,
334
+ sessionId: null,
335
+ normalizedOutput: null,
336
+ failureKind: 'spawn_error',
337
+ outputs: null,
338
+ };
339
+ state.finishedAt = nowISO();
340
+ ctx.setTaskStatus(taskId, 'blocked');
341
+ try {
342
+ await ctx.fireHook(taskId, 'task_failure');
343
+ } catch (hookErr) {
344
+ log.error(
345
+ `[task:${taskId}]`,
346
+ `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
347
+ );
348
+ }
349
+ if (ctx.getOnFailure(taskId) === 'stop_all') ctx.applyStopAll();
350
+ return;
344
351
  }
345
- if (ctx.getOnFailure(taskId) === 'stop_all') ctx.applyStopAll();
346
- return;
352
+ inferredPromptInputs = inputResolution.inputs;
347
353
  }
348
- const resolvedInputs = { ...bindingResolution.inputs, ...inputResolution.inputs };
354
+
355
+ const resolvedInputs = { ...inferredPromptInputs, ...bindingResolution.inputs };
349
356
  ctx.resolvedInputsMap.set(taskId, resolvedInputs);
350
- if (inputResolution.missingOptional.length > 0) {
351
- log.debug(
352
- `[task:${taskId}]`,
353
- `optional inputs unresolved (empty in placeholders): ${inputResolution.missingOptional.join(', ')}`,
354
- );
355
- }
356
357
  if (effectivePorts?.inputs && effectivePorts.inputs.length > 0) {
357
358
  log.debug(
358
359
  `[task:${taskId}]`,
@@ -413,10 +414,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
413
414
  // errors, so the only way to land here with an unresolved is an
414
415
  // optional input that had no upstream producer and no default,
415
416
  // which we surface in the log.
416
- const { text: expandedCommand, unresolved } = substituteInputs(
417
- task.command,
418
- resolvedInputs,
419
- );
417
+ const { text: expandedCommand, unresolved } = substituteInputs(task.command, resolvedInputs);
420
418
  if (unresolved.length > 0) {
421
419
  log.debug(
422
420
  `[task:${taskId}]`,
@@ -433,10 +431,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
433
431
  // Substitute placeholders in the user-authored prompt before
434
432
  // wrapping into a PromptDocument so middlewares see the
435
433
  // already-resolved task text.
436
- const { text: expandedPrompt, unresolved } = substituteInputs(
437
- task.prompt!,
438
- resolvedInputs,
439
- );
434
+ const { text: expandedPrompt, unresolved } = substituteInputs(task.prompt!, resolvedInputs);
440
435
  if (unresolved.length > 0) {
441
436
  log.debug(
442
437
  `[task:${taskId}]`,
@@ -460,10 +455,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
460
455
  }
461
456
  const mws = task.middlewares !== undefined ? task.middlewares : track.middlewares;
462
457
  if (mws && mws.length > 0) {
463
- log.debug(
464
- `[task:${taskId}]`,
465
- `middleware chain: ${mws.map((m) => m.type).join(' → ')}`,
466
- );
458
+ log.debug(`[task:${taskId}]`, `middleware chain: ${mws.map((m) => m.type).join(' → ')}`);
467
459
  const mwCtx: MiddlewareContext = {
468
460
  task,
469
461
  track,
@@ -480,11 +472,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
480
472
  // middleware's output becomes the new task body) but never
481
473
  // silently drops content.
482
474
  if (typeof mwPlugin.enhanceDoc === 'function') {
483
- const next = await mwPlugin.enhanceDoc(
484
- doc,
485
- mwConfig as Record<string, unknown>,
486
- mwCtx,
487
- );
475
+ const next = await mwPlugin.enhanceDoc(doc, mwConfig as Record<string, unknown>, mwCtx);
488
476
  if (
489
477
  !next ||
490
478
  typeof next !== 'object' ||
@@ -558,13 +546,6 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
558
546
  ...task,
559
547
  prompt,
560
548
  continue_from: node.resolvedContinueFrom,
561
- // Hand the driver the EFFECTIVE port schema rather than the
562
- // raw task.ports. For Prompt tasks this is the one inferred
563
- // from neighbor Commands; Command tasks are unchanged.
564
- // Drivers that introspect ports (e.g. to annotate a system
565
- // prompt with the I/O contract) otherwise saw `undefined`
566
- // for every prompt and had no way to know the contract.
567
- ports: effectivePorts,
568
549
  };
569
550
  const driverCtx: DriverContext = {
570
551
  sessionMap: ctx.sessionMap,
@@ -575,12 +556,8 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
575
556
  // contexts and task). Drivers that read task.prompt see the
576
557
  // default serialization and need no changes.
577
558
  promptDoc: doc,
578
- // Ports feature: resolved input values keyed by port name,
579
- // already coerced to the declared port type. Drivers that
580
- // need to re-substitute placeholders inside a custom envelope
581
- // can read this and call `substituteInputs`; most drivers can
582
- // ignore it because the engine has already expanded
583
- // `{{inputs.X}}` into `task.prompt` upstream.
559
+ // Resolved input values keyed by input name. Typed bindings have
560
+ // already been coerced when a binding declares `type`.
584
561
  inputs: resolvedInputs,
585
562
  };
586
563
  const spec = await driver.buildCommand(enrichedTask, track, driverCtx);
@@ -588,10 +565,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
588
565
  log.debug(`[task:${taskId}]`, `spawn args: ${JSON.stringify(spec.args)}`);
589
566
  if (spec.cwd) log.debug(`[task:${taskId}]`, `spawn cwd: ${spec.cwd}`);
590
567
  if (spec.env)
591
- log.debug(
592
- `[task:${taskId}]`,
593
- `spawn env overrides: ${Object.keys(spec.env).join(', ')}`,
594
- );
568
+ log.debug(`[task:${taskId}]`, `spawn env overrides: ${Object.keys(spec.env).join(', ')}`);
595
569
  if (spec.stdin) log.debug(`[task:${taskId}]`, `spawn stdin: ${spec.stdin.length} chars`);
596
570
  result = await runSpawn(spec, driver, runOpts);
597
571
  }
@@ -652,13 +626,11 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
652
626
  );
653
627
  if (outputExtraction.bindingDiagnostic) {
654
628
  log.debug(`[task:${taskId}]`, outputExtraction.bindingDiagnostic);
629
+ const note = `\n[engine] ${outputExtraction.bindingDiagnostic}`;
630
+ result = { ...result, stderr: result.stderr + note };
655
631
  }
656
632
  }
657
633
 
658
- // Prompt tasks use inferred ports (from direct-downstream Command
659
- // inputs); Command tasks use their declared ports. Either way,
660
- // `extractTaskOutputs` is a no-op when there are no declared
661
- // outputs to pull, so pre-ports tasks pay nothing for this call.
662
634
  if (effectivePorts?.outputs && effectivePorts.outputs.length > 0) {
663
635
  log.debug(
664
636
  `[task:${taskId}]`,
@@ -745,16 +717,10 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
745
717
  log.debug(`[task:${taskId}]`, `wrote stderr: ${result.stderrPath}`);
746
718
  }
747
719
  if (result.stdout) {
748
- log.quiet(
749
- `--- stdout (${taskId}) ---\n${clip(result.stdout)}\n--- end stdout ---`,
750
- taskId,
751
- );
720
+ log.quiet(`--- stdout (${taskId}) ---\n${clip(result.stdout)}\n--- end stdout ---`, taskId);
752
721
  }
753
722
  if (result.stderr) {
754
- log.quiet(
755
- `--- stderr (${taskId}) ---\n${clip(result.stderr)}\n--- end stderr ---`,
756
- taskId,
757
- );
723
+ log.quiet(`--- stderr (${taskId}) ---\n${clip(result.stderr)}\n--- end stderr ---`, taskId);
758
724
  }
759
725
  if (task.completion) {
760
726
  log.debug(