@tagma/sdk 0.6.7 → 0.6.8
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/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +108 -9
- package/dist/engine.js.map +1 -1
- package/dist/ports.d.ts +53 -1
- package/dist/ports.d.ts.map +1 -1
- package/dist/ports.js +142 -2
- package/dist/ports.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +19 -6
- package/dist/runner.js.map +1 -1
- package/dist/sdk.d.ts +2 -2
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +1 -1
- package/dist/sdk.js.map +1 -1
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +216 -31
- package/dist/validate-raw.js.map +1 -1
- package/package.json +6 -1
- package/src/engine-ports-mixed.test.ts +499 -0
- package/src/engine.ts +118 -9
- package/src/ports.test.ts +170 -0
- package/src/ports.ts +230 -3
- package/src/runner.test.ts +3 -3
- package/src/runner.ts +21 -5
- package/src/sdk.ts +10 -1
- package/src/validate-raw-ports.test.ts +234 -49
- package/src/validate-raw.ts +244 -34
package/src/engine.ts
CHANGED
|
@@ -30,7 +30,13 @@ import {
|
|
|
30
30
|
renderInputsBlock,
|
|
31
31
|
renderOutputSchemaBlock,
|
|
32
32
|
} from './prompt-doc';
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
extractTaskOutputs,
|
|
35
|
+
inferPromptPorts,
|
|
36
|
+
resolveTaskInputs,
|
|
37
|
+
substituteInputs,
|
|
38
|
+
} from './ports';
|
|
39
|
+
import type { TaskPorts } from './types';
|
|
34
40
|
import {
|
|
35
41
|
executeHook,
|
|
36
42
|
buildPipelineStartContext,
|
|
@@ -393,6 +399,20 @@ export async function runPipeline(
|
|
|
393
399
|
// just before a task runs, so every subsequent task_update event can
|
|
394
400
|
// echo them to the UI without re-resolving.
|
|
395
401
|
const resolvedInputsMap = new Map<string, Readonly<Record<string, unknown>>>();
|
|
402
|
+
// Reverse adjacency: for each task, list the direct-downstream task ids
|
|
403
|
+
// (tasks whose `depends_on` includes this one after DAG qualification).
|
|
404
|
+
// Computed once up front so Prompt-task port inference — which needs
|
|
405
|
+
// "what Commands directly consume me?" — is O(1) instead of O(tasks)
|
|
406
|
+
// per Prompt start. `dag.nodes` only exposes forward edges via
|
|
407
|
+
// `dependsOn`, so we build this locally.
|
|
408
|
+
const directDownstreams = new Map<string, string[]>();
|
|
409
|
+
for (const [id] of dag.nodes) directDownstreams.set(id, []);
|
|
410
|
+
for (const [id, node] of dag.nodes) {
|
|
411
|
+
for (const upstream of node.dependsOn) {
|
|
412
|
+
const list = directDownstreams.get(upstream);
|
|
413
|
+
if (list) list.push(id);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
396
416
|
|
|
397
417
|
// Pipeline timeout + abort reason tracking.
|
|
398
418
|
//
|
|
@@ -753,7 +773,83 @@ export async function runPipeline(
|
|
|
753
773
|
// Resolution runs even for tasks that declare no ports — the call
|
|
754
774
|
// is cheap and returns `{kind: 'ready', inputs: {}}` in that case,
|
|
755
775
|
// which downstream code handles uniformly.
|
|
756
|
-
|
|
776
|
+
//
|
|
777
|
+
// Prompt Tasks have no declared ports — their I/O contract is
|
|
778
|
+
// inferred from direct-neighbor Command Tasks (see ports.ts:
|
|
779
|
+
// `inferPromptPorts`). We synthesize a `TaskPorts` object and
|
|
780
|
+
// feed it into the same resolve/substitute/render/extract
|
|
781
|
+
// pipeline the Command path uses. Collisions that a Prompt can't
|
|
782
|
+
// disambiguate (same input name on two upstreams, incompatible
|
|
783
|
+
// downstream output types) block the task with a clear message.
|
|
784
|
+
const isPromptTask = task.prompt !== undefined && task.command === undefined;
|
|
785
|
+
let effectivePorts: TaskPorts | undefined = task.ports;
|
|
786
|
+
let promptInferenceBlockReason: string | null = null;
|
|
787
|
+
|
|
788
|
+
if (isPromptTask) {
|
|
789
|
+
const inference = inferPromptPorts({
|
|
790
|
+
upstreams: node.dependsOn.map((upstreamId) => {
|
|
791
|
+
const upstream = dag.nodes.get(upstreamId);
|
|
792
|
+
const isUpstreamCommand = !!upstream?.task.command;
|
|
793
|
+
return {
|
|
794
|
+
taskId: upstreamId,
|
|
795
|
+
outputs: isUpstreamCommand ? upstream?.task.ports?.outputs : undefined,
|
|
796
|
+
};
|
|
797
|
+
}),
|
|
798
|
+
downstreams: (directDownstreams.get(taskId) ?? []).map((downstreamId) => {
|
|
799
|
+
const downstream = dag.nodes.get(downstreamId);
|
|
800
|
+
const isDownstreamCommand = !!downstream?.task.command;
|
|
801
|
+
return {
|
|
802
|
+
taskId: downstreamId,
|
|
803
|
+
inputs: isDownstreamCommand ? downstream?.task.ports?.inputs : undefined,
|
|
804
|
+
};
|
|
805
|
+
}),
|
|
806
|
+
});
|
|
807
|
+
effectivePorts = inference.ports;
|
|
808
|
+
if (inference.inputConflicts.length > 0 || inference.outputConflicts.length > 0) {
|
|
809
|
+
const lines: string[] = [];
|
|
810
|
+
for (const c of inference.inputConflicts) lines.push(c.reason);
|
|
811
|
+
for (const c of inference.outputConflicts) lines.push(c.reason);
|
|
812
|
+
promptInferenceBlockReason = lines.join('\n');
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (promptInferenceBlockReason !== null) {
|
|
817
|
+
log.error(
|
|
818
|
+
`[task:${taskId}]`,
|
|
819
|
+
`blocked — prompt port inference failed:\n${promptInferenceBlockReason}`,
|
|
820
|
+
);
|
|
821
|
+
state.result = {
|
|
822
|
+
exitCode: -1,
|
|
823
|
+
stdout: '',
|
|
824
|
+
stderr: `[engine] prompt port inference failed:\n${promptInferenceBlockReason}`,
|
|
825
|
+
stdoutPath: null,
|
|
826
|
+
stderrPath: null,
|
|
827
|
+
durationMs: 0,
|
|
828
|
+
sessionId: null,
|
|
829
|
+
normalizedOutput: null,
|
|
830
|
+
failureKind: 'spawn_error',
|
|
831
|
+
outputs: null,
|
|
832
|
+
};
|
|
833
|
+
state.finishedAt = nowISO();
|
|
834
|
+
setTaskStatus(taskId, 'blocked');
|
|
835
|
+
try {
|
|
836
|
+
await fireHook(taskId, 'task_failure');
|
|
837
|
+
} catch (hookErr) {
|
|
838
|
+
log.error(
|
|
839
|
+
`[task:${taskId}]`,
|
|
840
|
+
`hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
if (getOnFailure(taskId) === 'stop_all') applyStopAll(node.track.id);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Feed effective ports into `resolveTaskInputs` by shallow-cloning
|
|
848
|
+
// the task. Prompt tasks get the inferred ports; Command tasks are
|
|
849
|
+
// unchanged (effectivePorts === task.ports).
|
|
850
|
+
const taskForResolve: TaskConfig =
|
|
851
|
+
effectivePorts === task.ports ? task : { ...task, ports: effectivePorts };
|
|
852
|
+
const inputResolution = resolveTaskInputs(taskForResolve, outputValuesMap, node.dependsOn);
|
|
757
853
|
if (inputResolution.kind === 'blocked') {
|
|
758
854
|
log.error(
|
|
759
855
|
`[task:${taskId}]`,
|
|
@@ -792,10 +888,11 @@ export async function runPipeline(
|
|
|
792
888
|
`optional inputs unresolved (empty in placeholders): ${inputResolution.missingOptional.join(', ')}`,
|
|
793
889
|
);
|
|
794
890
|
}
|
|
795
|
-
if (
|
|
891
|
+
if (effectivePorts?.inputs && effectivePorts.inputs.length > 0) {
|
|
796
892
|
log.debug(
|
|
797
893
|
`[task:${taskId}]`,
|
|
798
|
-
`resolved inputs: ${JSON.stringify(resolvedInputs)}
|
|
894
|
+
`resolved inputs: ${JSON.stringify(resolvedInputs)}` +
|
|
895
|
+
(isPromptTask ? ' (inferred from upstream Commands)' : ''),
|
|
799
896
|
);
|
|
800
897
|
}
|
|
801
898
|
|
|
@@ -888,11 +985,11 @@ export async function runPipeline(
|
|
|
888
985
|
// matters: [Output Format] first (sets the deliverable), then
|
|
889
986
|
// [Inputs] (the concrete data to operate on). Empty blocks are
|
|
890
987
|
// filtered out — tasks without ports get no extra blocks at all.
|
|
891
|
-
const outputFormatBlock = renderOutputSchemaBlock(
|
|
988
|
+
const outputFormatBlock = renderOutputSchemaBlock(effectivePorts?.outputs);
|
|
892
989
|
if (outputFormatBlock) {
|
|
893
990
|
doc = prependContext(doc, outputFormatBlock);
|
|
894
991
|
}
|
|
895
|
-
const inputsBlock = renderInputsBlock(
|
|
992
|
+
const inputsBlock = renderInputsBlock(effectivePorts?.inputs, resolvedInputs);
|
|
896
993
|
if (inputsBlock) {
|
|
897
994
|
doc = prependContext(doc, inputsBlock);
|
|
898
995
|
}
|
|
@@ -996,6 +1093,13 @@ export async function runPipeline(
|
|
|
996
1093
|
...task,
|
|
997
1094
|
prompt,
|
|
998
1095
|
continue_from: node.resolvedContinueFrom,
|
|
1096
|
+
// Hand the driver the EFFECTIVE port schema rather than the
|
|
1097
|
+
// raw task.ports. For Prompt tasks this is the one inferred
|
|
1098
|
+
// from neighbor Commands; Command tasks are unchanged.
|
|
1099
|
+
// Drivers that introspect ports (e.g. to annotate a system
|
|
1100
|
+
// prompt with the I/O contract) otherwise saw `undefined`
|
|
1101
|
+
// for every prompt and had no way to know the contract.
|
|
1102
|
+
ports: effectivePorts,
|
|
999
1103
|
};
|
|
1000
1104
|
const driverCtx: DriverContext = {
|
|
1001
1105
|
sessionMap,
|
|
@@ -1074,17 +1178,22 @@ export async function runPipeline(
|
|
|
1074
1178
|
// through driver-specific logs.
|
|
1075
1179
|
let extractedOutputs: Readonly<Record<string, unknown>> | null = null;
|
|
1076
1180
|
if (terminalStatus === 'success') {
|
|
1181
|
+
// Prompt tasks use inferred ports (from direct-downstream Command
|
|
1182
|
+
// inputs); Command tasks use their declared ports. Either way,
|
|
1183
|
+
// `extractTaskOutputs` is a no-op when there are no declared
|
|
1184
|
+
// outputs to pull, so pre-ports tasks pay nothing for this call.
|
|
1077
1185
|
const extraction = extractTaskOutputs(
|
|
1078
|
-
|
|
1186
|
+
effectivePorts,
|
|
1079
1187
|
result.stdout,
|
|
1080
1188
|
result.normalizedOutput,
|
|
1081
1189
|
);
|
|
1082
|
-
if (
|
|
1190
|
+
if (effectivePorts?.outputs && effectivePorts.outputs.length > 0) {
|
|
1083
1191
|
extractedOutputs = extraction.outputs;
|
|
1084
1192
|
outputValuesMap.set(taskId, extraction.outputs);
|
|
1085
1193
|
log.debug(
|
|
1086
1194
|
`[task:${taskId}]`,
|
|
1087
|
-
`extracted outputs: ${JSON.stringify(extraction.outputs)}
|
|
1195
|
+
`extracted outputs: ${JSON.stringify(extraction.outputs)}` +
|
|
1196
|
+
(isPromptTask ? ' (inferred from downstream Commands)' : ''),
|
|
1088
1197
|
);
|
|
1089
1198
|
if (extraction.diagnostic) {
|
|
1090
1199
|
log.error(`[task:${taskId}]`, extraction.diagnostic);
|
package/src/ports.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import {
|
|
3
3
|
extractInputReferences,
|
|
4
4
|
extractTaskOutputs,
|
|
5
|
+
inferPromptPorts,
|
|
5
6
|
resolveTaskInputs,
|
|
6
7
|
substituteInputs,
|
|
7
8
|
} from './ports';
|
|
@@ -299,3 +300,172 @@ describe('extractTaskOutputs', () => {
|
|
|
299
300
|
expect(r.diagnostic).toContain('could not find a final-line JSON object');
|
|
300
301
|
});
|
|
301
302
|
});
|
|
303
|
+
|
|
304
|
+
// ─── inferPromptPorts ───────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
describe('inferPromptPorts', () => {
|
|
307
|
+
test('inputs are taken from direct-upstream Command outputs', () => {
|
|
308
|
+
const r = inferPromptPorts({
|
|
309
|
+
upstreams: [
|
|
310
|
+
{
|
|
311
|
+
taskId: 't.up',
|
|
312
|
+
outputs: [
|
|
313
|
+
{ name: 'city', type: 'string' },
|
|
314
|
+
{ name: 'id', type: 'number' },
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
downstreams: [],
|
|
319
|
+
});
|
|
320
|
+
expect(r.inputConflicts).toEqual([]);
|
|
321
|
+
expect(r.outputConflicts).toEqual([]);
|
|
322
|
+
expect(r.ports.inputs).toHaveLength(2);
|
|
323
|
+
expect(r.ports.inputs?.map((p) => p.name).sort()).toEqual(['city', 'id']);
|
|
324
|
+
// Inferred inputs default to required: the LLM wouldn't see a real
|
|
325
|
+
// value if the upstream failed to produce one.
|
|
326
|
+
expect(r.ports.inputs?.every((p) => p.required === true)).toBe(true);
|
|
327
|
+
expect(r.ports.outputs).toBeUndefined();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('outputs are taken from direct-downstream Command inputs', () => {
|
|
331
|
+
const r = inferPromptPorts({
|
|
332
|
+
upstreams: [],
|
|
333
|
+
downstreams: [
|
|
334
|
+
{
|
|
335
|
+
taskId: 't.down',
|
|
336
|
+
inputs: [
|
|
337
|
+
{ name: 'greeting', type: 'string', required: true },
|
|
338
|
+
{ name: 'target', type: 'string', default: 'world' },
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
});
|
|
343
|
+
expect(r.outputConflicts).toEqual([]);
|
|
344
|
+
expect(r.ports.outputs?.map((p) => p.name).sort()).toEqual(['greeting', 'target']);
|
|
345
|
+
// Outputs drop input-only fields (required, default, from).
|
|
346
|
+
for (const p of r.ports.outputs ?? []) {
|
|
347
|
+
expect(p).not.toHaveProperty('required');
|
|
348
|
+
expect(p).not.toHaveProperty('default');
|
|
349
|
+
expect(p).not.toHaveProperty('from');
|
|
350
|
+
}
|
|
351
|
+
expect(r.ports.inputs).toBeUndefined();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('Prompt neighbors (outputs undefined) contribute nothing', () => {
|
|
355
|
+
const r = inferPromptPorts({
|
|
356
|
+
upstreams: [
|
|
357
|
+
{ taskId: 't.up', outputs: undefined }, // Prompt upstream
|
|
358
|
+
],
|
|
359
|
+
downstreams: [
|
|
360
|
+
{ taskId: 't.down', inputs: undefined }, // Prompt downstream
|
|
361
|
+
],
|
|
362
|
+
});
|
|
363
|
+
expect(r.ports).toEqual({});
|
|
364
|
+
expect(r.inputConflicts).toEqual([]);
|
|
365
|
+
expect(r.outputConflicts).toEqual([]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('two upstreams with the same output name produce an input conflict', () => {
|
|
369
|
+
const r = inferPromptPorts({
|
|
370
|
+
upstreams: [
|
|
371
|
+
{ taskId: 't.a', outputs: [{ name: 'city', type: 'string' }] },
|
|
372
|
+
{ taskId: 't.b', outputs: [{ name: 'city', type: 'string' }] },
|
|
373
|
+
],
|
|
374
|
+
downstreams: [],
|
|
375
|
+
});
|
|
376
|
+
expect(r.inputConflicts).toHaveLength(1);
|
|
377
|
+
expect(r.inputConflicts[0]!.portName).toBe('city');
|
|
378
|
+
expect(r.inputConflicts[0]!.producers.map((p) => p.taskId).sort()).toEqual(['t.a', 't.b']);
|
|
379
|
+
expect(r.inputConflicts[0]!.reason).toMatch(/cannot disambiguate/);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('two downstreams with compatible input types merge silently', () => {
|
|
383
|
+
const r = inferPromptPorts({
|
|
384
|
+
upstreams: [],
|
|
385
|
+
downstreams: [
|
|
386
|
+
{
|
|
387
|
+
taskId: 't.d1',
|
|
388
|
+
inputs: [{ name: 'date', type: 'string', required: true }],
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
taskId: 't.d2',
|
|
392
|
+
inputs: [{ name: 'date', type: 'string', required: false }],
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
});
|
|
396
|
+
expect(r.outputConflicts).toEqual([]);
|
|
397
|
+
expect(r.ports.outputs).toHaveLength(1);
|
|
398
|
+
expect(r.ports.outputs![0]!.name).toBe('date');
|
|
399
|
+
expect(r.ports.outputs![0]!.type).toBe('string');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('two downstreams with incompatible input types produce an output conflict', () => {
|
|
403
|
+
const r = inferPromptPorts({
|
|
404
|
+
upstreams: [],
|
|
405
|
+
downstreams: [
|
|
406
|
+
{ taskId: 't.d1', inputs: [{ name: 'date', type: 'string' }] },
|
|
407
|
+
{ taskId: 't.d2', inputs: [{ name: 'date', type: 'number' }] },
|
|
408
|
+
],
|
|
409
|
+
});
|
|
410
|
+
expect(r.outputConflicts).toHaveLength(1);
|
|
411
|
+
expect(r.outputConflicts[0]!.portName).toBe('date');
|
|
412
|
+
expect(r.outputConflicts[0]!.reason).toMatch(/conflicting type requirements/);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test('enum ports with differing value sets are incompatible', () => {
|
|
416
|
+
const r = inferPromptPorts({
|
|
417
|
+
upstreams: [],
|
|
418
|
+
downstreams: [
|
|
419
|
+
{
|
|
420
|
+
taskId: 't.d1',
|
|
421
|
+
inputs: [{ name: 'bucket', type: 'enum', enum: ['a', 'b'] }],
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
taskId: 't.d2',
|
|
425
|
+
inputs: [{ name: 'bucket', type: 'enum', enum: ['a', 'c'] }],
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
});
|
|
429
|
+
expect(r.outputConflicts).toHaveLength(1);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('enum ports with identical value sets merge', () => {
|
|
433
|
+
const r = inferPromptPorts({
|
|
434
|
+
upstreams: [],
|
|
435
|
+
downstreams: [
|
|
436
|
+
{
|
|
437
|
+
taskId: 't.d1',
|
|
438
|
+
inputs: [{ name: 'bucket', type: 'enum', enum: ['a', 'b'] }],
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
taskId: 't.d2',
|
|
442
|
+
inputs: [{ name: 'bucket', type: 'enum', enum: ['b', 'a'] }], // different order, same set
|
|
443
|
+
},
|
|
444
|
+
],
|
|
445
|
+
});
|
|
446
|
+
expect(r.outputConflicts).toEqual([]);
|
|
447
|
+
expect(r.ports.outputs).toHaveLength(1);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('description and enum propagate from the first occurrence', () => {
|
|
451
|
+
const r = inferPromptPorts({
|
|
452
|
+
upstreams: [
|
|
453
|
+
{
|
|
454
|
+
taskId: 't.up',
|
|
455
|
+
outputs: [
|
|
456
|
+
{
|
|
457
|
+
name: 'kind',
|
|
458
|
+
type: 'enum',
|
|
459
|
+
enum: ['hot', 'cold'],
|
|
460
|
+
description: 'Weather kind',
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
},
|
|
464
|
+
],
|
|
465
|
+
downstreams: [],
|
|
466
|
+
});
|
|
467
|
+
const port = r.ports.inputs![0]!;
|
|
468
|
+
expect(port.description).toBe('Weather kind');
|
|
469
|
+
expect(port.enum).toEqual(['hot', 'cold']);
|
|
470
|
+
});
|
|
471
|
+
});
|
package/src/ports.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// ═══ Task ports: substitute / resolve / extract ═══
|
|
1
|
+
// ═══ Task ports: substitute / resolve / extract / infer ═══
|
|
2
2
|
//
|
|
3
|
-
// One module,
|
|
3
|
+
// One module, four concerns, all keyed on `task.ports`:
|
|
4
4
|
//
|
|
5
5
|
// 1. `substituteInputs(text, inputs)` — expand `{{inputs.<name>}}` in
|
|
6
6
|
// user-authored strings (command lines, prompts). Strict syntax, no
|
|
@@ -22,10 +22,19 @@
|
|
|
22
22
|
// it. Prefer `normalizedOutput` for AI tasks, fall back to raw
|
|
23
23
|
// stdout — command tasks only ever have stdout.
|
|
24
24
|
//
|
|
25
|
+
// 4. `inferPromptPorts({upstreams, downstreams})` — Prompt Tasks do NOT
|
|
26
|
+
// declare ports; their I/O contract is inferred from direct-neighbor
|
|
27
|
+
// Command Tasks. This helper synthesizes a `TaskPorts` object the
|
|
28
|
+
// engine can feed into the three concerns above, and surfaces any
|
|
29
|
+
// collisions that block the task (same port name on two upstreams,
|
|
30
|
+
// incompatible types across downstreams, …). Prompt neighbors
|
|
31
|
+
// contribute zero structured I/O — they pass free text via
|
|
32
|
+
// `continue_from` / normalizedOutput instead.
|
|
33
|
+
//
|
|
25
34
|
// Everything here is pure / deterministic so it can be reused by the CLI,
|
|
26
35
|
// the editor (for preview/simulation), and the engine without side effects.
|
|
27
36
|
|
|
28
|
-
import type { PortDef, TaskConfig, TaskPorts } from './types';
|
|
37
|
+
import type { PortDef, PortType, TaskConfig, TaskPorts } from './types';
|
|
29
38
|
|
|
30
39
|
// ─── Template substitution ────────────────────────────────────────────
|
|
31
40
|
|
|
@@ -440,3 +449,221 @@ function safeParseJson(candidate: string): Record<string, unknown> | null {
|
|
|
440
449
|
}
|
|
441
450
|
return null;
|
|
442
451
|
}
|
|
452
|
+
|
|
453
|
+
// ─── Prompt-task port inference ───────────────────────────────────────
|
|
454
|
+
//
|
|
455
|
+
// Prompt Tasks have no declared ports. The engine calls `inferPromptPorts`
|
|
456
|
+
// to synthesize one from the Task's direct DAG neighbors:
|
|
457
|
+
//
|
|
458
|
+
// - **inputs** are taken from the declared `outputs` of every direct
|
|
459
|
+
// upstream Command Task. The union of names becomes the Prompt's
|
|
460
|
+
// inferred inputs. Upstream Prompt neighbors contribute nothing —
|
|
461
|
+
// information flows between Prompts as free text through
|
|
462
|
+
// `continue_from` / normalizedOutput, not through port values.
|
|
463
|
+
//
|
|
464
|
+
// - **outputs** are taken from the declared `inputs` of every direct
|
|
465
|
+
// downstream Command Task. The union of names becomes the Prompt's
|
|
466
|
+
// inferred outputs, which drives the `[Output Format]` block that
|
|
467
|
+
// tells the LLM what JSON to emit. Downstream Prompt neighbors
|
|
468
|
+
// contribute nothing (they just consume free text).
|
|
469
|
+
//
|
|
470
|
+
// Collisions:
|
|
471
|
+
//
|
|
472
|
+
// - **Input collision**: two upstream Commands both export an output
|
|
473
|
+
// named `city`. Command→Command would let a downstream add
|
|
474
|
+
// `from: taskId.city` to pick one; Prompt Tasks have no port
|
|
475
|
+
// declarations and therefore no escape hatch. The only fix is to
|
|
476
|
+
// rename on the Command side. We surface this as an `inputConflicts`
|
|
477
|
+
// entry; the engine blocks the task with that reason.
|
|
478
|
+
//
|
|
479
|
+
// - **Output collision with compatible types** (e.g. both downstreams
|
|
480
|
+
// ask for `date: string` with the same description) → merged into a
|
|
481
|
+
// single inferred output. The Prompt produces one `date`; both
|
|
482
|
+
// downstreams consume it.
|
|
483
|
+
//
|
|
484
|
+
// - **Output collision with incompatible types** (e.g. one downstream
|
|
485
|
+
// wants `date: string`, another `date: number`) → no single LLM
|
|
486
|
+
// emission can satisfy both. Surfaced as `outputConflicts`; engine
|
|
487
|
+
// blocks the task. User must rename on one side.
|
|
488
|
+
|
|
489
|
+
export interface PromptUpstreamNeighbor {
|
|
490
|
+
readonly taskId: string;
|
|
491
|
+
/**
|
|
492
|
+
* Declared outputs of the upstream task. `undefined` signals that the
|
|
493
|
+
* neighbor is a Prompt Task (no structured contribution) or otherwise
|
|
494
|
+
* has no outputs to offer. The inference logic treats `undefined` and
|
|
495
|
+
* an empty array the same way — neither contributes ports.
|
|
496
|
+
*/
|
|
497
|
+
readonly outputs: readonly PortDef[] | undefined;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export interface PromptDownstreamNeighbor {
|
|
501
|
+
readonly taskId: string;
|
|
502
|
+
/**
|
|
503
|
+
* Declared inputs of the downstream task. `undefined` signals a
|
|
504
|
+
* Prompt-Task neighbor or a Command Task without declared inputs.
|
|
505
|
+
* Either way it contributes no ports to the inferred output contract.
|
|
506
|
+
*/
|
|
507
|
+
readonly inputs: readonly PortDef[] | undefined;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export interface PromptPortConflict {
|
|
511
|
+
readonly portName: string;
|
|
512
|
+
readonly producers: readonly { readonly taskId: string; readonly type: PortType }[];
|
|
513
|
+
/** Pre-formatted human-readable reason for logs / stderr. */
|
|
514
|
+
readonly reason: string;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export interface PromptPortInference {
|
|
518
|
+
/**
|
|
519
|
+
* Synthetic `TaskPorts` the engine feeds into the resolve / substitute /
|
|
520
|
+
* render / extract helpers, exactly as if the Prompt had declared these
|
|
521
|
+
* ports itself. Empty arrays are preserved as absent so downstream code
|
|
522
|
+
* paths treat "no ports" uniformly (see engine.ts's existing
|
|
523
|
+
* `task.ports?.outputs && task.ports.outputs.length > 0` guard).
|
|
524
|
+
*/
|
|
525
|
+
readonly ports: TaskPorts;
|
|
526
|
+
readonly inputConflicts: readonly PromptPortConflict[];
|
|
527
|
+
readonly outputConflicts: readonly PromptPortConflict[];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Derive the effective `TaskPorts` for a Prompt Task from its direct
|
|
532
|
+
* neighbors. See the module-level "Prompt-task port inference" comment
|
|
533
|
+
* for the full contract.
|
|
534
|
+
*
|
|
535
|
+
* Pure function — no side effects, safe to call from the CLI, editor
|
|
536
|
+
* preview, and engine hot path alike.
|
|
537
|
+
*/
|
|
538
|
+
export function inferPromptPorts(input: {
|
|
539
|
+
readonly upstreams: readonly PromptUpstreamNeighbor[];
|
|
540
|
+
readonly downstreams: readonly PromptDownstreamNeighbor[];
|
|
541
|
+
}): PromptPortInference {
|
|
542
|
+
const { upstreams, downstreams } = input;
|
|
543
|
+
|
|
544
|
+
// ─── Inputs: union of upstream-Command outputs ─────────────────────
|
|
545
|
+
//
|
|
546
|
+
// Walk every upstream in DAG order. First occurrence of a name wins
|
|
547
|
+
// (for the synthesized port shape used to resolve values). Subsequent
|
|
548
|
+
// occurrences under the same name become an `inputConflicts` entry —
|
|
549
|
+
// the engine blocks the task because a Prompt can't disambiguate.
|
|
550
|
+
const inputsByName = new Map<string, { port: PortDef; firstProducer: string }>();
|
|
551
|
+
const inputCollisionSources = new Map<string, { taskId: string; type: PortType }[]>();
|
|
552
|
+
|
|
553
|
+
for (const upstream of upstreams) {
|
|
554
|
+
if (!upstream.outputs || upstream.outputs.length === 0) continue;
|
|
555
|
+
for (const out of upstream.outputs) {
|
|
556
|
+
const prior = inputsByName.get(out.name);
|
|
557
|
+
if (!prior) {
|
|
558
|
+
// Copy the shape verbatim but drop output-only fields and force
|
|
559
|
+
// `required: true`. Prompt-task inferred inputs are required by
|
|
560
|
+
// default: the LLM wouldn't be getting a real-world value
|
|
561
|
+
// otherwise, and substituting an empty string silently is the
|
|
562
|
+
// same kind of bug we already reject elsewhere.
|
|
563
|
+
inputsByName.set(out.name, {
|
|
564
|
+
port: {
|
|
565
|
+
name: out.name,
|
|
566
|
+
type: out.type,
|
|
567
|
+
...(out.description ? { description: out.description } : {}),
|
|
568
|
+
...(out.enum ? { enum: [...out.enum] } : {}),
|
|
569
|
+
required: true,
|
|
570
|
+
},
|
|
571
|
+
firstProducer: upstream.taskId,
|
|
572
|
+
});
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
// Collision — seed the source list with the first producer too so
|
|
576
|
+
// the emitted conflict lists *all* contributing producers.
|
|
577
|
+
const list = inputCollisionSources.get(out.name) ?? [
|
|
578
|
+
{ taskId: prior.firstProducer, type: prior.port.type },
|
|
579
|
+
];
|
|
580
|
+
list.push({ taskId: upstream.taskId, type: out.type });
|
|
581
|
+
inputCollisionSources.set(out.name, list);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const inputConflicts: PromptPortConflict[] = [];
|
|
586
|
+
for (const [portName, producers] of inputCollisionSources) {
|
|
587
|
+
const producerList = producers.map((p) => p.taskId).join(', ');
|
|
588
|
+
inputConflicts.push({
|
|
589
|
+
portName,
|
|
590
|
+
producers,
|
|
591
|
+
reason:
|
|
592
|
+
`input "${portName}" is produced by multiple upstream Commands (${producerList}) — ` +
|
|
593
|
+
`Prompt tasks cannot disambiguate (no explicit "from:" binding). ` +
|
|
594
|
+
`Rename the output on one of the upstream Commands.`,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ─── Outputs: union of downstream-Command inputs ───────────────────
|
|
599
|
+
//
|
|
600
|
+
// Compatible repeats merge (preserve first-encountered shape; prefer
|
|
601
|
+
// required when any downstream requires it). Incompatible repeats
|
|
602
|
+
// (different type, different enum set) go to `outputConflicts`.
|
|
603
|
+
const outputsByName = new Map<string, { port: PortDef; firstConsumer: string }>();
|
|
604
|
+
const outputCollisionSources = new Map<string, { taskId: string; type: PortType }[]>();
|
|
605
|
+
|
|
606
|
+
for (const downstream of downstreams) {
|
|
607
|
+
if (!downstream.inputs || downstream.inputs.length === 0) continue;
|
|
608
|
+
for (const inp of downstream.inputs) {
|
|
609
|
+
const prior = outputsByName.get(inp.name);
|
|
610
|
+
if (!prior) {
|
|
611
|
+
// Outputs drop input-only fields (required, default, from).
|
|
612
|
+
outputsByName.set(inp.name, {
|
|
613
|
+
port: {
|
|
614
|
+
name: inp.name,
|
|
615
|
+
type: inp.type,
|
|
616
|
+
...(inp.description ? { description: inp.description } : {}),
|
|
617
|
+
...(inp.enum ? { enum: [...inp.enum] } : {}),
|
|
618
|
+
},
|
|
619
|
+
firstConsumer: downstream.taskId,
|
|
620
|
+
});
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (portsAreCompatible(prior.port, inp)) continue; // merge silently
|
|
624
|
+
const list = outputCollisionSources.get(inp.name) ?? [
|
|
625
|
+
{ taskId: prior.firstConsumer, type: prior.port.type },
|
|
626
|
+
];
|
|
627
|
+
list.push({ taskId: downstream.taskId, type: inp.type });
|
|
628
|
+
outputCollisionSources.set(inp.name, list);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const outputConflicts: PromptPortConflict[] = [];
|
|
633
|
+
for (const [portName, producers] of outputCollisionSources) {
|
|
634
|
+
const consumerList = producers.map((p) => `${p.taskId} (${p.type})`).join(', ');
|
|
635
|
+
outputConflicts.push({
|
|
636
|
+
portName,
|
|
637
|
+
producers,
|
|
638
|
+
reason:
|
|
639
|
+
`output "${portName}" has conflicting type requirements across downstream Commands ` +
|
|
640
|
+
`(${consumerList}) — a single LLM emission cannot satisfy both. ` +
|
|
641
|
+
`Rename the input on one of the downstream Commands.`,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const inferredInputs = [...inputsByName.values()].map((e) => e.port);
|
|
646
|
+
const inferredOutputs = [...outputsByName.values()].map((e) => e.port);
|
|
647
|
+
|
|
648
|
+
const ports: TaskPorts = {
|
|
649
|
+
...(inferredInputs.length > 0 ? { inputs: inferredInputs } : {}),
|
|
650
|
+
...(inferredOutputs.length > 0 ? { outputs: inferredOutputs } : {}),
|
|
651
|
+
};
|
|
652
|
+
return { ports, inputConflicts, outputConflicts };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Two ports with the same name are compatible if they agree on `type`
|
|
657
|
+
* and, for enum ports, on the enum value set. Descriptions and
|
|
658
|
+
* required/default flags are deliberately ignored — they don't affect
|
|
659
|
+
* whether a single value can satisfy both consumers.
|
|
660
|
+
*/
|
|
661
|
+
function portsAreCompatible(a: PortDef, b: PortDef): boolean {
|
|
662
|
+
if (a.type !== b.type) return false;
|
|
663
|
+
if (a.type === 'enum') {
|
|
664
|
+
const aEnum = [...(a.enum ?? [])].sort().join('');
|
|
665
|
+
const bEnum = [...(b.enum ?? [])].sort().join('');
|
|
666
|
+
if (aEnum !== bEnum) return false;
|
|
667
|
+
}
|
|
668
|
+
return true;
|
|
669
|
+
}
|
package/src/runner.test.ts
CHANGED
|
@@ -55,10 +55,10 @@ test('runSpawn: oversized output — bounded tail in memory, full bytes on disk'
|
|
|
55
55
|
expect(result.exitCode).toBe(0);
|
|
56
56
|
// Total bytes reported match reality
|
|
57
57
|
expect(result.stdoutBytes).toBe(totalBytes);
|
|
58
|
-
// In-memory tail bounded (tail + truncation marker header is a
|
|
59
|
-
// hundred bytes at most; give it slack)
|
|
58
|
+
// In-memory tail bounded above (tail + truncation marker header is a
|
|
59
|
+
// couple hundred bytes at most; give it slack). No lower bound — chunk
|
|
60
|
+
// boundaries are platform-dependent so the exact retained size varies.
|
|
60
61
|
expect(result.stdout.length).toBeLessThan(cap + 1024);
|
|
61
|
-
expect(result.stdout.length).toBeGreaterThan(cap - 1024);
|
|
62
62
|
// Truncation breadcrumb present and points at the full output
|
|
63
63
|
expect(result.stdout).toContain('truncated from head');
|
|
64
64
|
expect(result.stdout).toContain(stdoutPath);
|
package/src/runner.ts
CHANGED
|
@@ -114,12 +114,20 @@ async function collectStream(
|
|
|
114
114
|
const chunks: Uint8Array[] = [];
|
|
115
115
|
let tailBytes = 0;
|
|
116
116
|
let totalBytes = 0;
|
|
117
|
-
|
|
117
|
+
let streamError: Error | null = null;
|
|
118
118
|
|
|
119
119
|
try {
|
|
120
|
-
for (
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
// Use for await...of to avoid Bun bug where getReader() returns an
|
|
121
|
+
// incomplete reader missing releaseLock() under concurrent spawn.
|
|
122
|
+
// https://github.com/oven-sh/bun/issues/28952
|
|
123
|
+
//
|
|
124
|
+
// Bun 1.3.x also has sporadic failures iterating a spawned process's
|
|
125
|
+
// stream under concurrent Bun.spawn — the iterator throws mid-drain even
|
|
126
|
+
// when the child exited 0. We record the error as a breadcrumb instead
|
|
127
|
+
// of propagating, so the caller still sees the real exitCode from
|
|
128
|
+
// proc.exited and a task that the OS considered successful doesn't get
|
|
129
|
+
// marked failed over a runtime stream glitch.
|
|
130
|
+
for await (const value of stream as AsyncIterable<Uint8Array>) {
|
|
123
131
|
totalBytes += value.length;
|
|
124
132
|
|
|
125
133
|
// Disk: persist every byte. Failure here degrades to tail-only mode
|
|
@@ -157,8 +165,12 @@ async function collectStream(
|
|
|
157
165
|
tailBytes = chunks[0]!.length;
|
|
158
166
|
}
|
|
159
167
|
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
streamError = err instanceof Error ? err : new Error(String(err));
|
|
170
|
+
console.error(
|
|
171
|
+
`[runner] stream read failed: ${streamError.message} — returning partial output`,
|
|
172
|
+
);
|
|
160
173
|
} finally {
|
|
161
|
-
reader.releaseLock();
|
|
162
174
|
if (fh) {
|
|
163
175
|
try {
|
|
164
176
|
await fh.close();
|
|
@@ -187,6 +199,10 @@ async function collectStream(
|
|
|
187
199
|
text = `[…${dropped} bytes truncated from head — full output at: ${pathHint}]\n${text}`;
|
|
188
200
|
}
|
|
189
201
|
|
|
202
|
+
if (streamError) {
|
|
203
|
+
text = text + `\n[runner] stream read aborted: ${streamError.message}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
190
206
|
return {
|
|
191
207
|
text,
|
|
192
208
|
totalBytes,
|