Skip to content

feat: run SkillFlows from the CLI, SDK and scheduler - #90

Open
Nivesh353 wants to merge 2 commits into
open-gitagent:mainfrom
Nivesh353:feat/core-flow-runner
Open

feat: run SkillFlows from the CLI, SDK and scheduler#90
Nivesh353 wants to merge 2 commits into
open-gitagent:mainfrom
Nivesh353:feat/core-flow-runner

Conversation

@Nivesh353

@Nivesh353 Nivesh353 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Closes #89

Problem

Core owns the SkillFlow format — discoverWorkflows, loadFlowDefinition, saveFlowDefinition, deleteFlowDefinition, and the workflows block injected into every system prompt. But the executor lived only in @open-gitagent/voice. So the CLI, SDK and scheduler could create, list and delete flows without ever being able to run one — while workflows.ts:156 told every agent, including the CLI's, that @flow_name worked.

Approach

Move the step loop into core as src/flow-runner.ts, with its side effects injected:

executeFlow(flow, input, { runStep, onProgress, requestApproval })
The loop owns ordering, prompt construction, {input} substitution, context threading and gate semantics. Each caller supplies only what's genuinely specific to it — voice passes its WebSocket broadcaster and Telegram/WhatsApp approver, the CLI passes console.log and a readline prompt, the scheduler passes a logger. The visual builder stays in voice; only the loop moved.

What's included

src/flow-runner.ts — the loop, plus a typed event stream (flow_start, step_start, step_done, approval_requested, approval_resolved, flow_done, flow_aborted) that front-ends render however they like.

src/run-flow.ts — runFlow(), a wrapper supplying the obvious default (each step is an isolated query() against the agent dir) so callers don't hand-write a runStep, plus per-step token usage:

const r = await runFlow({ agentDir, flow: "daily-report", input: "..." });
CLI — @flow-name [input] trigger and a /flows command. The trigger is anchored to line start, so an address like bob@daily-report.com can't fire a flow. Gates prompt on the existing readline instance. Both re-discover workflows rather than using the startup snapshot, matching how /skills calls refreshSkills.

Schedules — new optional flow field, so a schedule can run a SkillFlow instead of a single prompt (prompt becomes the flow's input). A flow schedule with no executor fails loudly rather than silently doing nothing.

Behaviour change worth reviewing

Approval gates now deny when no handler is supplied. A gate guards a risky step; with nobody to ask, continuing silently removes the protection it exists to provide. Unattended callers opt in explicitly with approve: "auto".

This changes voice's current behaviour, which auto-approves when no channel is connected — addressed in the companion voice PR.

Testing

21 new tests, none requiring an API key — gate-only flows resolve before any model call. Also verified end to end against a real agent:

CLI: /flows, @flow, gate approved and denied, unknown flow, and the anchored trigger correctly ignoring bob@daily-report.com
SDK: executeFlow with a custom runStep; runFlow across deny / "auto" / a human approver
A scheduled flow firing on cron and stopping at its gate
Per-step token accounting reconciled against billing to the last digit

SkillFlow definitions lived in core but the executor lived in
@open-gitagent/voice, so the CLI, SDK and scheduler could create and
list flows without ever being able to run one — while core's system
prompt told every agent that "@flow_name" worked.

Move the step loop into core as flow-runner.ts with its side effects
injected, so each front-end supplies only what is genuinely specific
to it:

  executeFlow(flow, input, { runStep, onProgress, requestApproval })

- CLI: "@flow-name [input]" trigger and a /flows command. The trigger
  is anchored to the start of the line so an address like
  bob@daily-report.com cannot fire a flow. Gates prompt on the
  existing readline instance.
- Schedules: new optional `flow` field, so a schedule can run a
  SkillFlow instead of a single prompt. `prompt` becomes the flow's
  input. A flow schedule with no executor fails loudly.
- SDK: runFlow() wraps executeFlow with the obvious defaults (run each
  step as an isolated query against the agent dir) and reports
  per-step token usage, so callers don't hand-write a runStep.

Approval gates now deny when no approval handler is supplied. A gate
guards a risky step; with nobody to ask, continuing silently removes
the protection it exists to provide. Unattended callers opt in with
`approve: "auto"`.

Per-step usage counts cache reads and writes, which the session
totals omit — a cache-heavy step otherwise reports near-zero input.

Verified end to end: CLI approve/deny, SDK with a custom runStep,
runFlow across all approval policies, and a scheduled flow. 21 new
tests, none requiring an API key.

@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 issues to address before merge — one is a correctness bug that affects real-world inputs, one is a correctness inconsistency between the CLI and SDK paths, and one is a cosmetic/waste issue worth cleaning up. The overall structure is solid: the loop, injection model, and approval gate semantics are well-designed, and the test coverage is thorough.

Security pass: no new dependencies added, no secrets in the diff, no obvious injection or authz gaps. The approval-gate-denies-by-default change is the right call.

Comment thread src/index.ts Outdated
Comment thread src/run-flow.ts Outdated
Comment thread src/flow-runner.ts
Review follow-ups on open-gitagent#89.

The @flow trigger required only a word boundary after the name, so
"@daily-report.com" fired the flow with input ".com" — a "." counts as
a boundary. Require whitespace or end-of-line instead, which rejects it
outright. The same line existed in the voice server; fixed there too.

resolveFlow matched by name regardless of type, then errored if the
result turned out to be a reference workflow. With both notes.md and
notes.yaml present, readdir order decided which one won, so a runnable
flow could be reported as "not runnable". Prefer a runnable match and
fall back to any match, keeping the more useful error when only a
markdown workflow exists.

Not changed: step 0 passing the user's input both through {input} and
the context block. Emptying the context block drops the input entirely
for flows whose first step doesn't template {input}, which is most of
them.

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

The follow-up commit addresses all three issues cleanly.

Regex anchor: (?=\s|$) replaces the word boundary — @daily-report.com no longer matches. The fix is also applied in the voice server.

resolveFlow: prefers a type === "flow" match before falling back to any match. The index.ts REPL path already filtered by type and is consistent.

Context doubling: the author made a deliberate call not to change the initial runningContext = userContext — correctly noted that emptying it would drop the input for flows whose first step doesn't template {input}. That's a reasonable trade-off and within scope for the author to own.

Security pass: no new dependencies, no secrets, no injection or authz gaps.

Looks good to merge.

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.

SkillFlow execution lives only in voice — CLI, SDK, and scheduler can't run flows

2 participants