feat: Implement workflow generation command with YAML validation and LLM integration#37
Conversation
|
Hey @shreyas-lyzr, pinging you on this one since you opened #9. Quick heads-up on scope: per your follow-up comment on the issue, this PR ships
That said, if you'd prefer to land it all in one go, I'm happy to extend this PR with:
Just let me know which way you want it:
Either way works for me, flagging early so we don't end up doing it twice. |
…LLM integration - Added `workflow.schema.json` for defining the structure of SkillFlow workflows. - Created `workflow.ts` to handle the `gitclaw workflow generate` command, including parsing flags and invoking the LLM. - Introduced `schemas.ts` for loading and validating workflows against the defined schema. - Developed `workflow-generator.ts` to manage LLM interactions and generate workflows based on user prompts. - Implemented tests for workflow generation and validation to ensure functionality and correctness. - Enhanced `package.json` test script for improved testing capabilities.
ee68b20 to
34ff957
Compare
shreyas-lyzr
left a comment
There was a problem hiding this comment.
Three blocking issues and a handful of medium/low concerns. The overall structure is solid — schema-on-disk, injectable LLM, retry loop, good test coverage — but the items below need to be addressed before merge.
| case "--refine": | ||
| flags.refine = argv[++i]; | ||
| break; | ||
| case "-m": |
There was a problem hiding this comment.
Blocking: argv[++i] silently produces undefined when a flag is the last token with no value (e.g. gitclaw workflow generate --prompt). For --prompt the downstream guard catches it, but --dir causes an unguarded resolve(undefined) in Node, which throws a confusing TypeError rather than a user-friendly message. For --api-key and --model the undefined propagates into the LLM call and surfaces far from the parse site.
Add a bounds check after each ++i:
| case "-m": | |
| case "--refine": | |
| if (i + 1 >= argv.length) { console.error(RED("--refine requires a value")); process.exit(2); } | |
| flags.refine = argv[++i]; | |
| break; |
Apply the same pattern to -d/--dir, -p/--prompt, -m/--model, and --api-key.
|
|
||
| let previousWorkflow: string | undefined; | ||
| if (flags.refine) { | ||
| const refinePath = resolve(agentDir, flags.refine); |
There was a problem hiding this comment.
Blocking: --refine accepts any path, including absolute paths and ../ traversals, and passes it directly to readFile without constraining it to the agent directory.
resolve(agentDir, '/etc/passwd') resolves to /etc/passwd (Node's path.resolve ignores the base when the second argument is absolute). A user could unintentionally (or intentionally) pass --refine /etc/hostname and have its content sent to the LLM.
Add a containment check before reading:
| const refinePath = resolve(agentDir, flags.refine); | |
| const refinePath = resolve(agentDir, flags.refine); | |
| if (!refinePath.startsWith(agentDir + path.sep) && refinePath !== agentDir) { | |
| throw new Error(`--refine path must be inside the agent directory: ${refinePath}`); | |
| } | |
| previousWorkflow = await readFile(refinePath, "utf-8"); |
| const composed = conversation | ||
| .map((m) => `[${m.role.toUpperCase()}]\n${m.content}`) | ||
| .join("\n\n"); | ||
|
|
There was a problem hiding this comment.
Blocking: stripCodeFences only strips fences when the entire string is a single fenced block (the regex is anchored with ^ and $). When the LLM wraps the YAML in commentary — "Here is your workflow:\nyaml\n...\n\nLet me know if..." — the regex does not match and the raw prose+fence is passed to the YAML parser, which will almost certainly fail, burning one of the two retry slots.
A more robust extractor that pulls the first fenced block regardless of surrounding text, and falls back to the raw trimmed text, would handle this reliably:
| const FENCE_INNER_RE = /```(?:ya?ml)?\s*\n([\s\S]*?)\n```/i; | |
| export function stripCodeFences(raw: string): string { | |
| const m = raw.match(FENCE_INNER_RE); | |
| return m ? m[1].trim() : raw.trim(); | |
| } |
The existing tests still pass with this change. Adding a test with leading/trailing prose would cement the behaviour.
| return { yaml }; | ||
| } | ||
|
|
||
| // Parse the validated YAML to get the workflow name for the file path. |
There was a problem hiding this comment.
Silent overwrite: writeFile unconditionally overwrites an existing workflow file with the same slugified name. If the LLM returns the same name: value on a second run, the previous file is silently replaced.
Consider checking whether the file exists first and either erroring out or appending a counter suffix, unless overwrite-by-default is intentional design. At minimum this should be documented in the help text.
| const issues: Issue[] = []; | ||
| validateAgainst(parsed, schema, "", schema, issues); | ||
|
|
||
| // Cross-field check: depends_on ids must reference declared step ids |
There was a problem hiding this comment.
The cross-field depends_on check verifies that referenced ids exist but does not detect cycles. A step whose depends_on includes its own id, or a cycle like A→B→A, passes validation today and would deadlock or loop at runtime.
This is not blocking if the runtime handles cycles gracefully, but worth a note or a future-issue if cycle detection is deferred.
| } | ||
|
|
||
| const [{ getModel }, { Agent }] = await Promise.all([ | ||
| import("@mariozechner/pi-ai" as any) as Promise<any>, |
There was a problem hiding this comment.
The import expression import("@mariozechner/pi-ai" as any) is a TypeScript-specific syntax (import(specifier as SomeType)). It works under tsc because TypeScript interprets it as a type assertion on the dynamic import, but it is not valid JavaScript and will break if the file is ever processed by a non-TypeScript tool (esbuild, rollup, swc in JS mode, node strip-types in a future version that tightens this).
The correct approach is to type the result after the import:
| import("@mariozechner/pi-ai" as any) as Promise<any>, | |
| const [piAi, piAgentCore] = await Promise.all([ | |
| import("@mariozechner/pi-ai") as Promise<any>, | |
| import("@mariozechner/pi-agent-core") as Promise<any>, | |
| ]); | |
| const { getModel } = piAi; | |
| const { Agent } = piAgentCore; |
This compiles identically under tsc but is syntactically clean.
shreyas-lyzr
left a comment
There was a problem hiding this comment.
Additional findings in loadFlowDefinition (src/workflows.ts) and its call site in src/voice/server.ts. This function is unchanged in the PR diff but is consumed by the new executeFlow path this PR introduces, so the bugs are activated by this PR.
1. Path traversal — blocking
server.ts:827 constructs the path from user-controlled input:
const flowPath = join(resolve(opts.agentDir), "workflows", flowName + ".yaml");
const flow = await loadFlowDefinition(flowPath);flowName comes from msg.text.match(/@([a-z0-9]+(?:-[a-z0-9]+)*)/) at server.ts:3136, which does constrain characters. But loadFlowDefinition accepts an arbitrary filePath: string and passes it straight to readFile with no bounds check. Any future caller that skips the regex — or any bypass in how chat input reaches this code — leads to arbitrary file read. The function should own its own safety: change its signature to (agentDir: string, flowName: string), validate flowName against the already-present KEBAB_RE, and construct the path internally. The call site becomes loadFlowDefinition(opts.agentDir, flowName).
2. yaml.load() returns non-object values — no explicit type guard
yaml.load() returns null for an empty file and can return a string or number on single-value YAML. The current guard:
if (!data?.name || !data?.steps || !Array.isArray(data.steps))uses optional chaining, so when data is null the condition correctly triggers — but the error says "missing name or steps" rather than "file is empty or not a mapping". More importantly, when data is a string (valid YAML), data?.name is undefined and the same misleading message fires. Add an explicit structural check first:
if (data === null || typeof data !== "object" || Array.isArray(data)) {
throw new Error("Invalid flow definition: file must be a YAML mapping");
}3. description not coerced to string
description: data.description || "" passes through a raw number or boolean if the YAML has description: 42. Any downstream code calling .length or other string methods on it throws. Change to String(data.description || "").
4. Per-step skill not validated for emptiness
String(s.skill || "") maps a missing or null skill to "". executeFlow then builds the prompt:
Use the skill "" (load it with /skill:).
and fires an LLM call with no error. After the steps.map(...), add:
const badStep = steps.findIndex((s) => !s.skill.trim());
if (badStep !== -1) throw new Error(`step[${badStep}] has an empty skill`);5. id / depends_on silently dropped — semantic mismatch with schema
The new workflow.schema.json and WorkflowStep in schemas.ts declare id and depends_on as supported fields. validateWorkflow accepts them. But loadFlowDefinition maps steps to SkillFlowStep which has neither field, so they are silently dropped. executeFlow then runs all steps in declaration order regardless of declared dependencies. A workflow author who writes depends_on based on the schema will get silent wrong behavior. Either: (a) add id?: string; depends_on?: string[] to SkillFlowStep and have executeFlow respect them, or (b) remove those fields from the schema so it doesn't advertise support for them.
Implemented workflow generation command with YAML validation and LLM integration
Resolves #9
What changed
New files
spec/schemas/workflow.schema.jsonsrc/utils/schemas.tsloadWorkflowSchema(),getWorkflowSchemaText(),validateWorkflow(yamlText) → { valid, errors[], data? }. Hand-rolled validator (no new deps).src/utils/workflow-generator.tsgenerateWorkflow({ prompt, skills, previousWorkflow?, model?, apiKey?, llm? })— builds the system prompt + two few-shot pairs (linear pipeline, approval step), calls the LLM, strips code fences, returns YAML.llmis injectable so tests don't hit the network.src/commands/workflow.tsgitclaw workflow generatewith-d / -p / --refine / -m / --api-key / --dry-run. Runs the 2-retry validation loop and writesworkflows/<slug>.yaml.test/workflow-validator.test.tstest/workflow-generator.test.tstest/ts-resolve-hook.mjsnode --experimental-strip-typescan follow Node16-style.jsinternal imports during tests.Modified files
src/index.tsimport { handleWorkflowCommand }and an early-dispatch branch forgitclaw workflow …(mirrors the existinggitclaw plugin …pattern).package.jsontestscript preloads the resolve hook sonpm testworks out of the box.CLI surface
Why these choices
1. Schema on disk, not inlined
Single source of truth.
spec/schemas/workflow.schema.jsonis loaded at runtime and embedded verbatim into the LLM system prompt — the validator and the generator cannot drift.2. Hand-rolled validator (no AJV)
Zero new deps. AJV isn't in
package.json; rather than addajv+ajv-formats, the validator implements only what this schema needs (type,required,additionalProperties,pattern,minItems,$ref, plus adepends_oncross-field check) and exposes the same{ valid, errors[] }contract.3. LLM via
@mariozechner/pi-ai(injectable)Reuse the existing stack. pi-ai is what gitclaw already uses everywhere — supports all providers, inherits telemetry and cost tracking. The
llm?option makes it swappable, so tests stay offline.4. Retry loop, not best-effort
Bounded self-healing. When the LLM emits invalid YAML, the CLI re-prompts up to 2x with the validator's errors appended; if still invalid it prints errors + the last YAML and
exit(1)— no unbounded loop, no silent failure.5. Argv dispatch, not Commander
Match the codebase. gitclaw has no Commander dep — it uses hand-rolled argv parsing with subcommand dispatch (see
gitclaw plugin …). Theworkflowsubcommand follows the same pattern.6. Flat
requires_approval, not nestedcompliance.*Stay aligned with runtime. gitclaw's existing SkillFlow shape is flat (
skill/prompt/channel). A nestedcomplianceobject would diverge from the loader — the flat field is what the runtime can act on today.Proof
1. Tests pass — 24/24
2. Typecheck clean
3. Pre-existing tests not regressed
Existing
test/telemetry.test.ts— 8/8 still pass under the newnpm testflags.test/sdk.test.tsfails in this environment, but the failure is pre-existing and unrelated: it importsdist/exports.js, which requiresnpm run buildagainst@mariozechner/*packages that aren't installed locally. Nothing in this PR touches the SDK surface.4. End-to-end retry behavior demonstrated in tests
The "retries on invalid YAML, succeeds on second attempt" test plumbs an
LlmClientthat returns invalid YAML once, then valid YAML — and asserts the retry prompt to the LLM included the words "schema validation". From the test output above:The "give up after 2 retries" test:
5. Schema-driven safety: example violations the validator catches
name(root): missing required property "name"name: MyWorkflow(not kebab-case)name: value "MyWorkflow" does not match pattern ^[a-z0-9]+(-[a-z0-9]+)*$steps: []steps: array must have at least 1 item(s), got 0promptsteps[0]: missing required property "prompt"nonsense: truesteps[0]: unknown property "nonsense"depends_on: [does_not_exist]steps[1].depends_on: references unknown step id "does_not_exist"YAML parse error: …Issue → PR mapping (acceptance checklist)
-p "<text>"generateWorkflow+ few-shot examples;depends_onexpresses orderingworkflows/<slug>.yaml--refine <file>modesrc/utils/workflow-generator.tssrc/utils/schemas.ts+ retry loop insrc/commands/workflow.tsNotes for review
tsconfig.json.pi-agent-core+pi-ai) is lazy-imported, so it never loads in tests.OPENAI_API_KEY/<PROVIDER>_API_KEYresolution falls back through--api-key, then provider-specific env, thenOPENAI_API_KEY. Missing key produces a clear error andexit(1).