Skip to content

feat: Implement workflow generation command with YAML validation and LLM integration - #37

Open
krishvsoni wants to merge 2 commits into
open-gitagent:mainfrom
krishvsoni:feat/issue-9-workflow-llm-generator
Open

feat: Implement workflow generation command with YAML validation and LLM integration#37
krishvsoni wants to merge 2 commits into
open-gitagent:mainfrom
krishvsoni:feat/issue-9-workflow-llm-generator

Conversation

@krishvsoni

Copy link
Copy Markdown

Implemented workflow generation command with YAML validation and LLM integration

Resolves #9

What changed

New files

File Purpose
spec/schemas/workflow.schema.json JSON Schema describing SkillFlow workflows. Single source of truth used by both the validator and the LLM system prompt.
src/utils/schemas.ts loadWorkflowSchema(), getWorkflowSchemaText(), validateWorkflow(yamlText) → { valid, errors[], data? }. Hand-rolled validator (no new deps).
src/utils/workflow-generator.ts generateWorkflow({ 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. llm is injectable so tests don't hit the network.
src/commands/workflow.ts CLI: gitclaw workflow generate with -d / -p / --refine / -m / --api-key / --dry-run. Runs the 2-retry validation loop and writes workflows/<slug>.yaml.
test/workflow-validator.test.ts 11 unit tests for the validator.
test/workflow-generator.test.ts 13 unit tests for prompt assembly, fence stripping, retry loop, dry-run, and refine mode (LLM mocked).
test/ts-resolve-hook.mjs Tiny ESM resolve hook so node --experimental-strip-types can follow Node16-style .js internal imports during tests.

Modified files

File Change
src/index.ts Added import { handleWorkflowCommand } and an early-dispatch branch for gitclaw workflow … (mirrors the existing gitclaw plugin … pattern).
package.json test script preloads the resolve hook so npm test works out of the box.

CLI surface

# Generate from scratch (writes workflows/<slug>.yaml)
gitclaw workflow generate -p "every morning summarize unread emails and post to Slack"

# Iteratively refine an existing workflow
gitclaw workflow generate \
  --refine workflows/morning-email-digest.yaml \
  -p "add a human approval step before the Slack post"

# Preview without writing
gitclaw workflow generate -p "..." --dry-run

# Override model / key
gitclaw workflow generate -p "..." -m anthropic:claude-sonnet-4-5 --api-key sk-...

Why these choices

1. Schema on disk, not inlined

Single source of truth. spec/schemas/workflow.schema.json is 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 add ajv + ajv-formats, the validator implements only what this schema needs (type, required, additionalProperties, pattern, minItems, $ref, plus a depends_on cross-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 …). The workflow subcommand follows the same pattern.

6. Flat requires_approval, not nested compliance.*

Stay aligned with runtime. gitclaw's existing SkillFlow shape is flat (skill/prompt/channel). A nested compliance object would diverge from the loader — the flat field is what the runtime can act on today.


Proof

1. Tests pass — 24/24

Screenshot 2026-05-17 at 00 58 43

2. Typecheck clean

$ npx tsc --noEmit
$ echo $?
0

3. Pre-existing tests not regressed

Existing test/telemetry.test.ts — 8/8 still pass under the new npm test flags.

test/sdk.test.ts fails in this environment, but the failure is pre-existing and unrelated: it imports dist/exports.js, which requires npm run build against @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 LlmClient that 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:

Generating workflow...
Retry 1/2 — fixing validation errors...
✔ runGenerate retries when validation fails, then writes the file when the second attempt is valid (6.11ms)

The "give up after 2 retries" test:

Generating workflow...
Retry 1/2 — fixing validation errors...
Retry 2/2 — fixing validation errors...
Workflow validation failed after retries:
  - (root): missing required property "name"
✔ runGenerate gives up after MAX_RETRIES and throws (0.50ms)

5. Schema-driven safety: example violations the validator catches

Input violation Reported error
YAML with no 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 0
Step missing prompt steps[0]: missing required property "prompt"
Step with unknown field nonsense: true steps[0]: unknown property "nonsense"
depends_on: [does_not_exist] steps[1].depends_on: references unknown step id "does_not_exist"
Truncated YAML YAML parse error: …

Issue → PR mapping (acceptance checklist)

  • Issue: "user describes the desired workflow in plain English"-p "<text>"
  • Issue: "LLM generates a complete SkillsFlow workflow with the correct nodes, order, and parameters"generateWorkflow + few-shot examples; depends_on expresses ordering
  • Issue: "User can review, edit, and save the generated workflow" — written as plain YAML to workflows/<slug>.yaml
  • Issue: "Support iterative refinement"--refine <file> mode
  • Issue (author's scope): "Component 1 — LLM prompt template (system + few-shot)"src/utils/workflow-generator.ts
  • Issue (author's scope): "Component 2 — schema validator that catches malformed output before saving"src/utils/schemas.ts + retry loop in src/commands/workflow.ts
  • Issue: "Component 3 — UI integration into the SkillsFlow builder page (Studio side)" — explicitly out of scope per the issue author

Notes for review

  • No new npm dependencies were added.
  • All new TS compiles under the existing strict tsconfig.json.
  • The default LLM client (pi-agent-core + pi-ai) is lazy-imported, so it never loads in tests.
  • OPENAI_API_KEY / <PROVIDER>_API_KEY resolution falls back through --api-key, then provider-specific env, then OPENAI_API_KEY. Missing key produces a clear error and exit(1).

@krishvsoni

Copy link
Copy Markdown
Author

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

  • Components 1 and 2 :
    (the LLM prompt template + few-shot examples, and the schema validator with a 2-retry self-healing loop).

  • Component 3: the SkillsFlow builder UI integration on the Studio side is intentionally out of scope here, matching what you flagged as "lives in Studio."

That said, if you'd prefer to land it all in one go, I'm happy to extend this PR with:

  • a minimal chat input wired into the SkillsFlow page builder
  • a "Generate" action that calls gitclaw workflow generate (or the underlying generateWorkflow() SDK export) and renders the YAML back into the visual builder
  • the refine flow (--refine) surfaced as a follow-up prompt in the same chat

Just let me know which way you want it:

  1. Merge as-is (OSS-only scope, Studio UI tracked separately), or
  2. Extend this PR to cover the Studio UI too give me the rough shape you want
    (which page/component, where the chat lives, how the generated workflow should be applied to the canvas)
    & I'll push another commit.

Either way works for me, flagging early so we don't end up doing it twice.

@krishvsoni
krishvsoni force-pushed the feat/issue-9-workflow-llm-generator branch from ee68b20 to 34ff957 Compare May 25, 2026 16:06

@shreyas-lyzr shreyas-lyzr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/commands/workflow.ts
Comment thread src/commands/workflow.ts
Comment thread src/utils/workflow-generator.ts
Comment thread src/commands/workflow.ts
Comment thread src/utils/schemas.ts
Comment thread src/utils/workflow-generator.ts Outdated

@shreyas-lyzr shreyas-lyzr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

…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.
@krishvsoni
krishvsoni force-pushed the feat/issue-9-workflow-llm-generator branch from 34ff957 to 6f8f1a4 Compare July 28, 2026 16:08
@krishvsoni

Copy link
Copy Markdown
Author

Thanks for the detailed review, @shreyas-lyzr. All points have been addressed. Summary below.

Blocking

1. argv[++i] with no value

Added a valueFor(argv, i, flag) helper that prints <flag> requires a value and exits with code 2 when a flag is the last token.

Applied to:

  • -d / --dir
  • -p / --prompt
  • --refine
  • -m / --model
  • --api-key

2. --refine path traversal

--refine is now constrained to the agent directory.

The resolved path must either:

  • equal agentDir, or
  • start with agentDir + sep

Otherwise, it throws before readFile() is called.

Both --refine /etc/hostname and ../../ paths are now rejected.

3. stripCodeFences anchored regex

Switched to the unanchored FENCE_INNER_RE you suggested.

It now:

  • extracts the first fenced code block even when the model wraps it in prose
  • falls back to the raw trimmed text if no fenced block exists

Added a test covering leading and trailing commentary.

Medium / Low

4. Silent overwrite

generate now refuses to overwrite an existing workflows/<name>.yaml.

The error instructs the user to rerun with --force or use --dry-run.

Also added:

  • -f / --force
  • documentation for both --force and the --refine containment rule in the help text

5. depends_on cycles

Added cycle detection instead of deferring it.

findDependencyCycle() performs a DFS over the depends_on graph and reports the cycle, for example:

depends_on cycle detected: a -> b -> a

The validator now rejects:

  • self-references
  • A → B → A cycles

Added three tests, including a diamond graph that still passes.

6. import("..." as any)

Changed to assign the modules first, then destructure getModel and Agent.

loadFlowDefinition findings

1. Path traversal (blocking)

Changed the signature to:

loadFlowDefinition(agentDir: string, flowName: string)

The function now:

  • validates flowName against KEBAB_RE
  • constructs the path internally

This ensures the function owns its own path safety.

This is a breaking change to a symbol exported from src/exports.ts.

The src/voice/server.ts call site mentioned in the review does not exist in this repository, so there were no call sites to update here.

If that code lives in Studio, the call becomes:

loadFlowDefinition(opts.agentDir, flowName)

2. yaml.load type guard

Added an explicit structural check.

Empty files and scalar documents now fail with:

Invalid flow definition: file must be a YAML mapping

instead of the previous misleading "missing name or steps" error.

3. description coercion

Changed to:

String(doc.description || "")

4. Empty per-step skill

Added validation for empty skill values.

The loader now throws:

Invalid flow definition: step[N] has an empty skill

5. id and depends_on dropped

Went with option (a).

Changes:

  • Added id, depends_on, and requires_approval to SkillFlowStep.
  • Moved step mapping into a shared normalizeSteps() used by both discoverWorkflows() and loadFlowDefinition().
  • Updated saveFlowDefinition() to preserve these fields so round-tripping no longer loses data.

Since steps execute in declaration order, the loader also validates that every depends_on references a step declared earlier in the file.

This makes declaration-order execution correct by construction instead of silently ignoring the declared graph.

A full topological executor would require changes to executeFlow, which is not part of this repository, so I left that as a follow-up if needed.

Tests

Added and updated tests covering:

  • prose-wrapped fenced code blocks
  • overwrite refusal and --force
  • --refine outside the agent directory
  • the three cycle detection cases
  • a new test/workflows.test.ts with seven tests covering the loader fixes

All 37/37 tests pass across the three workflow test files.

npx tsc --noEmit reports only the pre-existing src/mcp/manager.ts errors caused by @modelcontextprotocol/sdk not being installed locally. None of the files touched in this PR introduce TypeScript errors.

The remaining npm test failures are also pre-existing: ERR_MODULE_NOT_FOUND errors for dist/* in sdk, compact, cost-tracker, knowledge, mcp, and tool-utils. Those test suites import build output and require npm run build first.

One thing not in the review

package.json currently includes only dist in its files list, so spec/schemas/workflow.schema.json is omitted from the published package.

I confirmed this with npm pack --dry-run.

As a result, loadWorkflowSchema() throws for anyone installing from npm.

Adding "spec" to the files array fixes the issue.

Happy to include that in this PR or submit it separately if you prefer.

@krishvsoni
krishvsoni requested a review from shreyas-lyzr July 28, 2026 16:11
@Nivesh353

Nivesh353 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

@krishvsoni

  1. spec/schemas/workflow.schema.json missing from the npm package — since you've already diagnosed this yourself — go ahead and include that fix in this PR rather than a separate one, so workflow generate actually works for anyone installing via npm rather than only from a full git checkout.

  2. loadFlowDefinition's signature change breaks the existing @open-gitagent/voice package. It's a publicly exported function (src/exports.ts), and this PR changes it from loadFlowDefinition(filePath) to loadFlowDefinition(agentDir, flowName).
    That'll break the moment voice updates its gitagent dependency — and not with a clear "signature changed" error either: passing one argument doesn't get caught by the new validation (the kebab-case check on the missing second argument passes anyway, since undefined coerces to the string "undefined", which happens to look valid), so it fails downstream with a confusing "file not found" instead. Would it be possible to support both call styles (detect a single full-path argument vs. the new two-argument form), so existing callers don't silently break?

@Nivesh353

Copy link
Copy Markdown
Contributor

@krishvsoni Question about skill handling in the generator — does workflow generate only produce the workflow YAML based on the skills it finds installed, or does it also generate the SKILL.md/scripts for any new skill it references?

Asking because I tested it locally against a project with 18 real installed skills (none related to weather/SMS), and asked for: "check the weather every morning and text me a summary." It generated this without error:

name: morning-weather-summary
description: Check the weather each morning and send a text summary.
steps:

  • id: fetch_weather
    skill: weather
    prompt: Fetch today's weather forecast including temperature, precipitation, and any notable conditions for the user's configured location.
  • id: send_summary
    skill: sms
    prompt: Send a concise one or two sentence weather summary for the day to the user's configured phone number.
    depends_on: [fetch_weather]
    Neither weather nor sms exists anywhere in that project's skills/ folder — no new skill files were created either. So this workflow currently references two skills that don't exist and wouldn't run.

Is this expected — i.e. is the user meant to notice the mismatch and create those skills themselves afterward — or should the generator (or the validator) catch this and either refuse/retry when the prompt doesn't match any real installed skill?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: dynamically build SkillsFlow workflows via chat LLM

3 participants