smithers-orchestrator 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smithers-orchestrator",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "AI workflow orchestration with JSX",
5
5
  "author": "William Cory",
6
6
  "license": "MIT",
@@ -1,5 +1,14 @@
1
1
  import type { OutputKey } from "./OutputKey";
2
2
  import type { OutputAccessor, InferOutputEntry } from "./OutputAccessor";
3
+ import type { z } from "zod";
4
+
5
+ /**
6
+ * Reverse-lookup: given Schema and a value type V, find the key K where Schema[K] extends V.
7
+ * Used to narrow return types when passing Zod schema objects directly.
8
+ */
9
+ type SchemaKeyForValue<Schema, V> = {
10
+ [K in keyof Schema & string]: Schema[K] extends V ? K : never;
11
+ }[keyof Schema & string];
3
12
 
4
13
  export interface SmithersCtx<Schema> {
5
14
  runId: string;
@@ -8,22 +17,49 @@ export interface SmithersCtx<Schema> {
8
17
  input: Schema extends { input: infer T } ? T : Record<string, unknown>;
9
18
  outputs: OutputAccessor<Schema>;
10
19
 
20
+ // Overload: pass Zod schema value directly → narrowed return type
21
+ output<V extends z.ZodTypeAny>(
22
+ table: V,
23
+ key: OutputKey,
24
+ ): SchemaKeyForValue<Schema, V> extends never
25
+ ? InferOutputEntry<V>
26
+ : InferOutputEntry<V>;
27
+
28
+ // Overload: pass string key → narrowed via K
11
29
  output<K extends keyof Schema & string>(
12
30
  table: K,
13
31
  key: OutputKey,
14
32
  ): InferOutputEntry<Schema[K]>;
15
33
 
34
+ // Overload: pass Zod schema value directly → narrowed return type
35
+ outputMaybe<V extends z.ZodTypeAny>(
36
+ table: V,
37
+ key: OutputKey,
38
+ ): SchemaKeyForValue<Schema, V> extends never
39
+ ? InferOutputEntry<V> | undefined
40
+ : InferOutputEntry<V> | undefined;
41
+
42
+ // Overload: pass string key → narrowed via K
16
43
  outputMaybe<K extends keyof Schema & string>(
17
44
  table: K,
18
45
  key: OutputKey,
19
46
  ): InferOutputEntry<Schema[K]> | undefined;
20
47
 
48
+ // Overload: pass Zod schema value directly → narrowed return type
49
+ latest<V extends z.ZodTypeAny>(
50
+ table: V,
51
+ nodeId: string,
52
+ ): SchemaKeyForValue<Schema, V> extends never
53
+ ? InferOutputEntry<V> | undefined
54
+ : InferOutputEntry<V> | undefined;
55
+
56
+ // Overload: pass string key → narrowed via K
21
57
  latest<K extends keyof Schema & string>(
22
58
  table: K,
23
59
  nodeId: string,
24
60
  ): InferOutputEntry<Schema[K]> | undefined;
25
61
 
26
- latestArray(value: unknown, schema: import("zod").ZodType): any[];
62
+ latestArray(value: unknown, schema: z.ZodType): any[];
27
63
 
28
64
  iterationCount(table: any, nodeId: string): number;
29
65
  }
@@ -17,9 +17,8 @@ export type TaskDescriptor = {
17
17
  retries: number;
18
18
  timeoutMs: number | null;
19
19
  continueOnFail: boolean;
20
- agent?: AgentLike;
21
- /** Fallback agent used on retry when the primary agent fails (e.g. rate-limited). */
22
- fallbackAgent?: AgentLike;
20
+ /** Agent or array of agents [primary, fallback1, fallback2, ...]. Tries in order until one succeeds. */
21
+ agent?: AgentLike | AgentLike[];
23
22
  prompt?: string;
24
23
  staticPayload?: unknown;
25
24
  computeFn?: () => unknown | Promise<unknown>;
@@ -0,0 +1,94 @@
1
+ import {
2
+ BaseCliAgent,
3
+ pushFlag,
4
+ } from "./BaseCliAgent";
5
+ import type { BaseCliAgentOptions } from "./BaseCliAgent";
6
+
7
+ type AmpAgentOptions = BaseCliAgentOptions & {
8
+ workDir?: string;
9
+ thread?: string;
10
+ visibility?: "private" | "public" | "workspace" | "group";
11
+ quiet?: boolean;
12
+ mcpConfig?: string;
13
+ settingsFile?: string;
14
+ logLevel?: "error" | "warn" | "info" | "debug" | "audit";
15
+ logFile?: string;
16
+ dangerouslyAllowAll?: boolean;
17
+ ide?: boolean;
18
+ jetbrains?: boolean;
19
+ };
20
+
21
+ export class AmpAgent extends BaseCliAgent {
22
+ private readonly opts: AmpAgentOptions;
23
+
24
+ constructor(opts: AmpAgentOptions = {}) {
25
+ super(opts);
26
+ this.opts = opts;
27
+ }
28
+
29
+ protected async buildCommand(params: {
30
+ prompt: string;
31
+ systemPrompt?: string;
32
+ cwd: string;
33
+ options: any;
34
+ }) {
35
+ const args: string[] = ["threads", "continue"];
36
+ const yoloEnabled = this.opts.yolo ?? this.yolo;
37
+
38
+ // Working directory
39
+ pushFlag(args, "--work-dir", this.opts.workDir ?? params.cwd);
40
+
41
+ // Thread ID (if continuing existing thread)
42
+ pushFlag(args, "--thread", this.opts.thread);
43
+
44
+ // Visibility for new threads
45
+ pushFlag(args, "--visibility", this.opts.visibility);
46
+
47
+ // Model
48
+ pushFlag(args, "--model", this.opts.model ?? this.model);
49
+
50
+ // Quiet mode
51
+ if (this.opts.quiet) args.push("--quiet");
52
+
53
+ // MCP config
54
+ pushFlag(args, "--mcp-config", this.opts.mcpConfig);
55
+
56
+ // Settings file
57
+ pushFlag(args, "--settings-file", this.opts.settingsFile);
58
+
59
+ // Log level
60
+ pushFlag(args, "--log-level", this.opts.logLevel);
61
+
62
+ // Log file
63
+ pushFlag(args, "--log-file", this.opts.logFile);
64
+
65
+ // Dangerous allow all (yolo mode)
66
+ if (this.opts.dangerouslyAllowAll || yoloEnabled) {
67
+ args.push("--dangerously-allow-all");
68
+ }
69
+
70
+ // IDE integration
71
+ if (this.opts.ide === false) args.push("--no-ide");
72
+ if (this.opts.jetbrains === false) args.push("--no-jetbrains");
73
+
74
+ // Color handling
75
+ args.push("--no-color"); // Disable color for clean output parsing
76
+
77
+ if (this.extraArgs?.length) args.push(...this.extraArgs);
78
+
79
+ // Build prompt with system prompt prepended
80
+ const systemPrefix = params.systemPrompt
81
+ ? `${params.systemPrompt}\n\n`
82
+ : "";
83
+ const fullPrompt = `${systemPrefix}${params.prompt ?? ""}`;
84
+
85
+ // Amp accepts prompt as final argument
86
+ args.push(fullPrompt);
87
+
88
+ return {
89
+ command: "amp",
90
+ args,
91
+ outputFormat: "text" as const,
92
+ };
93
+ }
94
+ }
@@ -190,6 +190,7 @@ export function extractTextFromJsonValue(value: any): string | undefined {
190
190
  .join("");
191
191
  if (parts.trim()) return parts;
192
192
  }
193
+ if (value.response) return extractTextFromJsonValue(value.response);
193
194
  if (value.message) return extractTextFromJsonValue(value.message);
194
195
  if (value.result) return extractTextFromJsonValue(value.result);
195
196
  if (value.output) return extractTextFromJsonValue(value.output);
@@ -780,6 +781,7 @@ export abstract class BaseCliAgent implements Agent<any, any, any> {
780
781
  outputFormat === "json" || outputFormat === "stream-json"
781
782
  ? (extractTextFromJsonPayload(rawText) ?? rawText)
782
783
  : rawText;
784
+
783
785
  const output = tryParseJson(extractedText);
784
786
  return buildGenerateResult(
785
787
  extractedText,
@@ -40,7 +40,10 @@ export class GeminiAgent extends BaseCliAgent {
40
40
  }) {
41
41
  const args: string[] = [];
42
42
  const yoloEnabled = this.opts.yolo ?? this.yolo;
43
- const outputFormat = this.opts.outputFormat ?? "text";
43
+ // Default to "json" output format to separate model responses from tool
44
+ // output text. With "text" format, tool call results (file contents etc.)
45
+ // are concatenated into the response, making JSON extraction unreliable.
46
+ const outputFormat = this.opts.outputFormat ?? "json";
44
47
 
45
48
  if (this.opts.debug) args.push("--debug");
46
49
  pushFlag(args, "--model", this.opts.model ?? this.model);
@@ -70,7 +73,12 @@ export class GeminiAgent extends BaseCliAgent {
70
73
  const systemPrefix = params.systemPrompt
71
74
  ? `${params.systemPrompt}\n\n`
72
75
  : "";
73
- const fullPrompt = `${systemPrefix}${params.prompt ?? ""}`;
76
+ // Reinforce JSON output requirement in the prompt for Gemini models which
77
+ // tend to forget structured output instructions on long responses.
78
+ const jsonReminder = params.prompt?.includes("REQUIRED OUTPUT")
79
+ ? "\n\nREMINDER: Your response MUST end with a ```json code fence containing the required JSON object. Do NOT skip this step — the pipeline will reject your response without it.\n"
80
+ : "";
81
+ const fullPrompt = `${systemPrefix}${params.prompt ?? ""}${jsonReminder}`;
74
82
  args.push("--prompt", fullPrompt);
75
83
 
76
84
  return {
@@ -46,9 +46,14 @@ export class KimiAgent extends BaseCliAgent {
46
46
  // Note: --print implicitly adds --yolo
47
47
  args.push("--print");
48
48
 
49
- // Output format
50
- const outputFormat = this.opts.outputFormat ?? "stream-json";
49
+ // Output format — use text with --final-message-only to get only the
50
+ // model's final response without tool call outputs mixed in.
51
+ const outputFormat = this.opts.outputFormat ?? "text";
51
52
  pushFlag(args, "--output-format", outputFormat);
53
+ // When using text format, --final-message-only ensures we only get
54
+ // the model's final response, not intermediate tool output.
55
+ const finalMessageOnly = this.opts.finalMessageOnly ?? (outputFormat === "text");
56
+ if (finalMessageOnly) args.push("--final-message-only");
52
57
 
53
58
  // Other flags
54
59
  pushFlag(args, "--work-dir", this.opts.workDir ?? params.cwd);
@@ -76,7 +81,10 @@ export class KimiAgent extends BaseCliAgent {
76
81
  const systemPrefix = params.systemPrompt
77
82
  ? `${params.systemPrompt}\n\n`
78
83
  : "";
79
- const fullPrompt = `${systemPrefix}${params.prompt ?? ""}`;
84
+ const jsonReminder = params.prompt?.includes("REQUIRED OUTPUT")
85
+ ? "\n\nREMINDER: Your response MUST end with a ```json code fence containing the required JSON object. Do NOT skip this step — the pipeline will reject your response without it.\n"
86
+ : "";
87
+ const fullPrompt = `${systemPrefix}${params.prompt ?? ""}${jsonReminder}`;
80
88
 
81
89
  // Pass prompt via --prompt flag
82
90
  pushFlag(args, "--prompt", fullPrompt);
@@ -1,5 +1,7 @@
1
1
  export { BaseCliAgent } from "./BaseCliAgent";
2
2
 
3
+ export { AmpAgent } from "./AmpAgent";
4
+
3
5
  export { ClaudeCodeAgent } from "./ClaudeCodeAgent";
4
6
 
5
7
  export { CodexAgent } from "./CodexAgent";
@@ -8,9 +8,8 @@ export type TaskProps<Row> = {
8
8
  key?: string;
9
9
  id: string;
10
10
  output: import("zod").ZodObject<any>;
11
- agent?: AgentLike;
12
- /** Fallback agent used on retry when the primary agent fails (e.g. rate-limited). */
13
- fallbackAgent?: AgentLike;
11
+ /** Agent or array of agents [primary, fallback1, fallback2, ...]. Tries in order on retries. */
12
+ agent?: AgentLike | AgentLike[];
14
13
  skipIf?: boolean;
15
14
  needsApproval?: boolean;
16
15
  timeoutMs?: number;
@@ -208,7 +208,6 @@ export function extractFromHost(
208
208
  const continueOnFail = Boolean(raw.continueOnFail);
209
209
 
210
210
  const agent = raw.agent;
211
- const fallbackAgent = raw.fallbackAgent;
212
211
  const kind = raw.__smithersKind;
213
212
  const isAgent = kind === "agent" || Boolean(agent);
214
213
  const prompt = isAgent ? String(raw.children ?? "") : undefined;
@@ -243,7 +242,6 @@ export function extractFromHost(
243
242
  timeoutMs,
244
243
  continueOnFail,
245
244
  agent,
246
- fallbackAgent,
247
245
  prompt,
248
246
  staticPayload,
249
247
  computeFn,
@@ -778,9 +778,9 @@ async function executeTask(
778
778
  }
779
779
 
780
780
  if (!payload) {
781
- const effectiveAgent =
782
- attemptNo > 1 && desc.fallbackAgent ? desc.fallbackAgent : desc.agent;
783
- if (desc.agent) {
781
+ const agents = Array.isArray(desc.agent) ? desc.agent : (desc.agent ? [desc.agent] : []);
782
+ const effectiveAgent = agents[Math.min(attemptNo - 1, agents.length - 1)];
783
+ if (effectiveAgent) {
784
784
  // Use fallback agent on retry attempts when available
785
785
  const result = await runWithToolContext(
786
786
  {
@@ -802,15 +802,23 @@ async function executeTask(
802
802
  let effectivePrompt = desc.prompt ?? "";
803
803
  if (desc.outputTable) {
804
804
  const schemaDesc = describeSchemaShape(desc.outputTable as any, desc.outputSchema);
805
- effectivePrompt += [
806
- "",
807
- "",
805
+ const jsonInstructions = [
808
806
  "**REQUIRED OUTPUT** — You MUST end your response with a JSON object in a code fence matching this schema:",
809
807
  "```json",
810
808
  schemaDesc,
811
809
  "```",
812
810
  "Output the JSON at the END of your response. The workflow will fail without it.",
813
811
  ].join("\n");
812
+ // Prepend a brief reminder at the top AND append full instructions at the end.
813
+ // This ensures models with long outputs don't lose track of the JSON requirement.
814
+ effectivePrompt = [
815
+ "IMPORTANT: After completing the task below, you MUST output a JSON object in a ```json code fence at the very end of your response. Do NOT forget this — the workflow fails without it.",
816
+ "",
817
+ effectivePrompt,
818
+ "",
819
+ "",
820
+ jsonInstructions,
821
+ ].join("\n");
814
822
  }
815
823
  const emitOutput = (text: string, stream: "stdout" | "stderr") => {
816
824
  eventBus.emit("event", {
@@ -869,7 +877,7 @@ async function executeTask(
869
877
  // Not valid JSON, try extraction
870
878
  }
871
879
 
872
- // Helper to extract balanced JSON from text
880
+ // Helper to extract balanced JSON from text (first occurrence)
873
881
  function extractBalancedJson(str: string): string | null {
874
882
  const start = str.indexOf("{");
875
883
  if (start === -1) return null;
@@ -902,13 +910,27 @@ async function executeTask(
902
910
  return null;
903
911
  }
904
912
 
913
+ // Helper to extract the LAST balanced JSON object in text.
914
+ // Agents like Kimi emit all intermediate tool output before the final
915
+ // required JSON, so searching from the end finds the right object.
916
+ function extractLastBalancedJson(str: string): string | null {
917
+ let pos = str.lastIndexOf("{");
918
+ while (pos >= 0) {
919
+ const json = extractBalancedJson(str.slice(pos));
920
+ if (json !== null) return json;
921
+ pos = str.lastIndexOf("{", pos - 1);
922
+ }
923
+ return null;
924
+ }
925
+
905
926
  // Try to extract JSON from code fence (```json ... ```)
906
927
  if (output === undefined) {
907
- // Check text first - look for code fence with balanced JSON
908
- const codeFenceStart = text.search(/```(?:json)?\s*\{/);
909
- if (codeFenceStart !== -1) {
928
+ // Find the LAST code fence the required output is always at the end
929
+ const allFences = [...text.matchAll(/```(?:json)?\s*\{/g)];
930
+ const lastFence = allFences[allFences.length - 1];
931
+ if (lastFence?.index !== undefined) {
910
932
  const afterFence = text
911
- .slice(codeFenceStart)
933
+ .slice(lastFence.index)
912
934
  .replace(/```(?:json)?\s*/, "");
913
935
  const jsonStr = extractBalancedJson(afterFence);
914
936
  if (jsonStr) {
@@ -965,9 +987,10 @@ async function executeTask(
965
987
  }
966
988
  }
967
989
 
968
- // Try text itself
990
+ // Try text itself — search from END so we get the required output JSON,
991
+ // not an earlier JSON object from intermediate tool output
969
992
  if (output === undefined) {
970
- const jsonStr = extractBalancedJson(text);
993
+ const jsonStr = extractLastBalancedJson(text);
971
994
  if (jsonStr) {
972
995
  try {
973
996
  const parsed = JSON.parse(jsonStr);
@@ -983,8 +1006,16 @@ async function executeTask(
983
1006
  // If no JSON found, send a follow-up prompt asking for just the JSON with schema info
984
1007
  if (output === undefined && desc.agent) {
985
1008
  const schemaDesc = describeSchemaShape(desc.outputTable as any, desc.outputSchema);
1009
+ // Include a truncated summary of the original response so the model has context
1010
+ const responseSummary = text.length > 2000
1011
+ ? text.slice(0, 1000) + "\n...[truncated]...\n" + text.slice(-1000)
1012
+ : text;
986
1013
  const jsonPrompt = [
987
- `You have completed your task. Now you MUST output ONLY a valid JSON object (no other text) with exactly these fields and types:`,
1014
+ `You previously completed a task and produced this response (possibly truncated):`,
1015
+ ``,
1016
+ responseSummary,
1017
+ ``,
1018
+ `Now you MUST output ONLY a valid JSON object (no other text) summarizing your work above, with exactly these fields and types:`,
988
1019
  schemaDesc,
989
1020
  ``,
990
1021
  `Output ONLY the JSON object, nothing else.`,
@@ -1028,7 +1059,8 @@ async function executeTask(
1028
1059
  console.log(
1029
1060
  `[JSON Debug] finishReason=${finishReason}, text.length=${text.length}, steps.count=${debugSteps.length}`,
1030
1061
  );
1031
- console.log(`[JSON Debug] text preview: ${text.slice(0, 300)}`);
1062
+ console.log(`[JSON Debug] text start: ${text.slice(0, 300)}`);
1063
+ console.log(`[JSON Debug] text end: ${text.slice(-500)}`);
1032
1064
  console.log(
1033
1065
  `[JSON Debug] last step text: ${debugSteps[debugSteps.length - 1]?.text?.slice(0, 500) ?? "none"}`,
1034
1066
  );
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export {
29
29
 
30
30
  // Agents
31
31
  export {
32
+ AmpAgent,
32
33
  ClaudeCodeAgent,
33
34
  CodexAgent,
34
35
  GeminiAgent,