Skip to content

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

Open
krishvsoni wants to merge 6 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 6 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 Outdated
Comment thread src/utils/schemas.ts Outdated
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?

@krishvsoni

Copy link
Copy Markdown
Author

Thanks @Nivesh353, all three points are handled.

  • Schema missing from the npm package. Fixed here rather than in a separate PR, as you asked. spec is now in the files list, so the schema ships and workflow generate works for npm installs, not just git checkouts.

  • loadFlowDefinition breaking voice. Both call styles work now. loadFlowDefinition(filePath) keeps working for voice, and loadFlowDefinition(agentDir, flowName) is the preferred form. Your read of the failure was right, so I picked the form by argument count instead of inspecting the string. That way a missing second argument can never quietly become the string "undefined" and pass the kebab check. The old form is marked deprecated and prints one line the first time it runs, so voice keeps working today and still knows to migrate.

  • Skills that do not exist. Real gap, not intended behaviour. To answer the question: generate only writes the workflow YAML, and I would keep it that way. Authoring a skill needs instructions, often scripts, and sometimes credentials, so creating one as a side effect of "make me a workflow" felt surprising. That belongs in its own command.

The actual bug was that nothing checked the output. The prompt told the model to use only installed skills, but the validator only looked at schema shape, so your weather and sms example passed straight through.

Now the generated steps are checked against the installed skills. A mismatch goes into the same retry loop as schema errors, so the model first gets a chance to rebuild the workflow with real skills, and if it still cannot, generation fails and nothing is written. I went with refuse rather than warn because a workflow that cannot run is worse than no file at all, and the failure is much cheaper to understand at generate time than at run time.

Two exceptions on purpose. approval is allowed since it is the pseudo skill for approval steps and never exists under skills/. And the check is skipped when no skills are installed at all, because on a fresh project the prompt deliberately asks the model to invent sensible names, so enforcing it there would block generation entirely. For the case where you do want a workflow ahead of its skills, there is --allow-missing-skills.

On your example with 18 unrelated skills, that prompt now fails and tells you weather and sms are not installed, lists what is, and points at the flag.

@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.

Reviewed the full changed code across all 11 files. This is a well-structured addition — the design choices (no new deps, injectable LLM client, retry loop, schema as single source of truth, path containment on --refine) are all sound. Tests cover the important paths.

One correctness gap worth a follow-up (not blocking given current runtime behavior):

The schema validator (validateWorkflow in schemas.ts) builds the entire declared-id set before checking depends_on references. This means a forward reference — step[0] depends_on an id declared by step[1] — passes validation and gets written to disk. The runtime loader (loadFlowDefinition) catches it with its sequential available-set check, so the workflow fails loudly at load time rather than silently, but the error surfaces to the user after file write rather than during generation. The retry loop never sees it, so the LLM doesn't get a chance to fix it.

Fix: change the validator's depends_on check to use the same forward-only set that loadFlowDefinition uses (add ids to the set as you visit each step, not in a separate pre-pass). That closes the gap between what validate accepts and what the runtime permits.

Everything else looks correct: the path traversal guard on --refine is solid (resolve + sep-prefixed startsWith, tested for absolute and ../ forms), kebab-case gating on loadFlowDefinition(agentDir, flowName) is a clear improvement over the previous unchecked form, skill-reference cross-check + approval exemption works, cycle detection covers self-ref and mutual cycles, and the module-level schema cache is fine for a CLI process.

Security pass: no new dependencies added, no hardcoded secrets or keys, no exposed frontend env vars, no injection paths (user-supplied --prompt and --refine content goes to the LLM under the user's own API key as intended). The --refine containment is correct. Clean.

Comment thread src/utils/schemas.ts Outdated
@krishvsoni

Copy link
Copy Markdown
Author

Thanks @shreyas-lyzr, good catch on the forward reference gap. Fixed rather than left as a follow-up, since it was a small change and the whole point of the retry loop is that the model gets to see its own mistakes.

The validator now grows the id set as it walks the steps, instead of pre-building it from every step. So a step depending on an id declared later is reported right there as an unknown id, with a note that it has to be declared in a preceding step. That matches what loadFlowDefinition already enforced, so validate and the runtime now agree on what is legal, and the failure happens during generation instead of after the file is written.

I kept the explicit cycle check even though you are right that it is now redundant for the forward reference case. A back edge does get caught as an unknown id, but depends_on cycle detected: a -> b -> a says a lot more about what is actually wrong than two separate unknown id errors do, and it is a cheap pass to keep.

Everything else you flagged earlier is in as well, and thanks for the security pass.

@Nivesh353

Copy link
Copy Markdown
Contributor

@krishvsoni One more thing, not blocking — a softer version of the same issue.

Reran the weather/SMS case. It now correctly rejects made-up skill names (Retry 1/2 — fixing validation errors), but the retry lands on a real skill that's still wrong for the task:

  • skill: internal-comms
    prompt: Write a concise morning weather briefing...
    internal-comms is for company announcements, not weather/SMS. So it no longer references something nonexistent, but it'll still silently do the wrong thing at runtime, no warning.

"Does this skill exist" is an easy check; "is this skill actually right for the task" is a much fuzzier one

@krishvsoni

Copy link
Copy Markdown
Author

@Nivesh353 I reproduced it.

The issue is that both prompts encourage the model to force a match instead of saying it can't do the task.

  • src/utils/workflow-generator.ts:35: "express the request using the installed skills even if that means fewer steps"
  • src/commands/workflow.ts:181: the retry prompt only says "referenced skills that are not installed. Fix these errors."

On retry, the model just picks a skill name that passes validation, so it ends up using internal-comms.

The skill descriptions are already included in the system prompt (workflow-generator.ts:78), so the problem is not missing context. The model just has no way to say "this isn't supported."

My plan:

  1. Give the model a way to say a task is unsupported. Remove the "even if that means fewer steps" instruction and let it return something like unsupported: [fetch weather, send SMS] instead of replacing those steps with the wrong skill. That gives the user a clear error instead of a workflow that looks valid but is wrong.
  2. Update the retry prompt so it asks for a correct workflow, not just one that passes validation.
  3. Add a simple fitness warning. Compare each step with the selected skill description and show a warning if the match looks weak. It should only be a warning, not a validation error, since this is subjective and we don't want false positives blocking valid workflows.

I'm confident about (1) and (2).

For (3), do you think a warning is the right approach? Or would you rather skip the check completely and just show the selected skill and its description so users can judge the match themselves? I lean toward a warning, but either approach could work.

@Nivesh353

Copy link
Copy Markdown
Contributor

@krishvsoni Go with the warning over skipping the check

@krishvsoni

Copy link
Copy Markdown
Author

@Nivesh353 Went with the warning. All three parts are in.

Cause. Two prompts told the model to force a fit instead of saying it can't do the task. workflow-generator.ts:35 said "express the request using the installed skills even if that means fewer steps", and the retry in workflow.ts:181 only said "fix these errors". So the model picked whatever name passes validation. The skill descriptions were already in the prompt, so it wasn't missing context. It just had no way to say no.

1. The model can decline now. Removed the "even if that means fewer steps" line. It can return this instead of substituting:

unsupported:
  - fetch the current weather forecast
  - send a text message

New parseUnsupportedReport() in src/utils/schemas.ts. Two notes on it:

  • It runs before validateWorkflow, not after. The schema has additionalProperties: false, so an unsupported doc would be rejected as an unknown property and the retry would re-apply the same pressure we are removing.
  • A decline is never retried. Retrying is what caused the internal-comms substitution in the first place. It prints the uncovered items plus the installed list and writes nothing.

I did not add unsupported to workflow.schema.json. The schema stays a description of a workflow only. Tell me if you want it at schema level instead.

2. Retry prompt. Now says correctness beats passing the check, don't swap in a skill whose description doesn't cover the step, and use unsupported: if nothing covers it.

3. Fitness warning. New src/utils/skill-fitness.ts. Compares each step with its chosen skill's description and asks for obvious mismatches only. The prompt says returning nothing is the expected answer, since a false positive is worse than a miss here.

Three things so a bug in the checker can never cost someone a workflow:

  • Skips the LLM call when nothing is judgeable (no skills installed, or every step is approval or an uninstalled skill).
  • Bad output degrades to zero warnings instead of throwing. Covers fences, prose, broken JSON, out of range indices.
  • A provider error is swallowed. A 429 costs you the warning, not the workflow.

It is a warning, never a validation error, and it does not feed the retry loop.

1 step(s) may use the wrong skill for the task:
  step 1 - skill "internal-comms": internal-comms publishes company-wide staff announcements;
           this step needs a weather forecast for one user.
    step prompt: Write a concise morning weather briefing for today's forecast.

Tests. npm test 130/130 pass, 17 new. tsc --noEmit clean.

Then I ran your case live against a real model, 3 runs each, skills installed being gmail, internal-comms, slack, summarize:

scenario result
1. "check the weather every morning and text me a summary" declined 3/3, on the first attempt, no file written
2. control: "summarize my unread email and post it to Slack every morning" built gmail, summarize, slack 3/3. 0 warnings
3a. fitness judge on the internal-comms weather workflow flagged step 1, 3/3
3b. fitness judge on a workflow where internal-comms is correct silent 3/3

On 1 it never substituted. It listed "check the current weather forecast" and "send a text message" as uncovered and stopped. So the escape hatch alone fixes the case you found, and the fitness pass is the second line of defence for when it doesn't.

3a and 3b are the fitness judge on its own, since scenario 1 now declines before the judge is reached. 3b matters more than 3a: it uses internal-comms for an actual staff announcement, which is the obvious false positive to guard against, and it stayed quiet.

Open question. The fitness pass costs one extra LLM call per generate. I left it on by default with --no-fitness-check to opt out, since a warning nobody turns on is useless. Happy to flip it to opt-in if you'd rather not pay that every time.

@Nivesh353

Copy link
Copy Markdown
Contributor

@krishvsoni Tested this — working fine feature-wise. The unsupported: path correctly caught the weather/SMS case instead of forcing a wrong skill, and a real success case (internal-comms + docx) generated a clean, correctly-ordered workflow with no false-positive fitness warnings.

Two things worth addressing:

Model is hardcoded to openai:gpt-4o, ignoring agent.yaml. Even with Anthropic fully configured, this command defaults to OpenAI unless --model is passed. Preference order should be: -m/--model flag first, then agent.yaml's model.preferred second, hardcoded default last.

Confirmed live: none of this is cost-tracked, even with OpenTelemetry connected. I ran workflow generate with OTEL_TRACES_EXPORTER=console and OTEL_EXPORTER_OTLP_ENDPOINT set — zero spans printed. Ran the same env against a normal interactive session as a control — got a full gen_ai.chat span with token/cost data, as expected. Root cause: src/index.ts's workflow subcommand branch returns before initTelemetry() is ever called, so telemetry never initializes at all for this path, generation + retries + the new fitness check included.

Also — could you generate a few more SkillFlows across different prompts/skill combos, just to make sure this is solid more broadly?

@krishvsoni

Copy link
Copy Markdown
Author

@Nivesh353 Thanks for testing it properly. Both issues confirmed and fixed.

1. Model now respects agent.yaml. Order is exactly what you asked: -m/--model, then model.preferred, then the built-in default. In resolveModel() in src/commands/workflow.ts. The command also prints Model: ... on start so it is obvious which one won.

Related, and probably the other half of why "Anthropic fully configured" didn't help: this path never loaded .env either, so ANTHROPIC_API_KEY from ~/.gitagent/.env or the agent's .env was invisible to it. It does now.

I left model_override from env config out of this. loader.ts:385 puts it above the CLI flag, which contradicts the order you asked for, so I stuck to your order rather than inventing a third precedence rule. Easy to add if you want parity.

2. Telemetry. Your root cause was right. The .env load and initTelemetry() were inline in main(), so any subcommand that returned early skipped them. I moved all three of those steps into src/utils/bootstrap.ts and both paths call it now, instead of duplicating the logic. Also added a gitagent.workflow.generate span, and generation, retries and the fitness pass all report gen_ai.chat spans with tokens and cost.

Two things I found while verifying, both worth a look:

  • The spans were not nesting. Each LLM call became its own root trace, so you could see individual costs but not sum one generation run. Fixed with context.with, so a run is now one trace.
  • Token attributes were under-reporting. usage.input only counts the uncached prefix, so with prompt caching a 1523-token call reported input_tokens: 3 while cost_usd said $0.0086. The cost was right, the token count was not. Added cache_read_input_tokens and cache_creation_input_tokens. This is in the shared recordGenAiCall, so it affects the interactive path too. It is additive and existing attributes are unchanged, but say the word if you want it as a separate PR.

Also worth noting: the plugin subcommand branch returns early the same way, so it has the same telemetry gap. I did not touch it here.

Verified live with OTEL_TRACES_EXPORTER=console and an agent.yaml set to anthropic:claude-sonnet-4-6, no -m flag:

Model: anthropic:claude-sonnet-4-6
gen_ai.chat   gen_ai.system=anthropic  input=3 output=179  cost_usd=0.008574   <- generation
gen_ai.chat   gen_ai.system=anthropic  input=398 output=4  cost_usd=0.001254   <- fitness pass
gitagent.workflow.generate   model=anthropic:claude-sonnet-4-6  attempts=1  fitness_warnings=0

So the fitness pass costs about 15 percent of a generate on this prompt. That is a better basis for the on-by-default question than my guess was.

Tests. 139/139 pass, tsc --noEmit clean. New: 5 for model precedence including a missing and a malformed agent.yaml, and 4 in test/workflow-telemetry.test.ts asserting the parent/child span relationship, the cache token attributes, and that a failed generation marks the span as an error.

3. Broader SkillFlow testing. Partly done. Across 12 live runs earlier I had: the weather case declining 3/3, a covered request building cleanly 3/3 with zero warnings, the fitness judge catching the internal-comms misfit 3/3, and staying silent 3/3 on a workflow where internal-comms was genuinely correct. I have not yet done the wider sweep across more prompt and skill-set combinations you asked for. That is next, and I will post the results here rather than claim it is covered.

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