15 KiB
tinyflows — Node I/O Alignment & Selectable Agent Kinds (POA)
Status: proposed · Date: 2026-07-05 Scope: (A) fix the node input/output contract so producer/consumer shapes line up (agent ↔ toolcall ↔ merge), and (B) let a workflow pick _which kind of agent an
agentnode runs (coding agent, researcher, crypto agent, …) so each brings its own curated tool access. Companion: extendsdocs/plans/tinyflows-integration/README.md; audit sourcevendor/tinyflows/docs/AUDIT-n8n-gap.md(§3 bugs, §4b I/O alignment). Reference product: n8n — declared per-node output schemas, per-item execution, AI Agent node with pluggable tool/model sub-nodes.
0. Why this plan
Two problems surfaced in the audit that share a root cause — nodes have no declared I/O contract, so what one node emits and what the next reads are only accidentally aligned:
- Shape drift. Every capability node wraps its host capability's raw return verbatim into
Item.json(agent.rs:115,tool_call.rs:31,http_request.rs:24). Theagentnode emits three different shapes depending on sub-ports, and its plain shape flips between "parsed model JSON" and{text}at runtime (caps.rs:359,369). Downstream=item.<field>expressions therefore guess. - No agent identity. The
agentnode is a single bareprovider.chatcall with a free-form inlinetoolslist (caps.rsOpenHumanLlm::complete, "no agent loop is driven here"). There is no way to say "run this step as the coding agent" or "as the researcher", even though OpenHuman already ships a 35-agent registry where each agent (researcher,code_executor,crypto_agent, …) declares its own toolset, model hint, sandbox, and iteration policy in anagent.toml.
Part A fixes the contract; Part B builds selectable agent kinds on top of the existing registry. They are sequenced so A1 (the agent envelope) lands before B (agent kinds emit into that same envelope).
Part A — Node I/O alignment
A0. Design principle: a normalized item envelope
Adopt a small, stable output envelope for capability nodes so every downstream expression has a guaranteed accessor, regardless of provider or config:
// agent / tool_call / http_request / code emit items shaped:
{
"json": <structured payload | null>, // parsed/structured result when there is one
"text": <string | null>, // human-readable text when there is one
"raw": <provider-native value>, // escape hatch: the untouched capability return
"error": <null | { message, ... }> // present only on continue/route error items
}
Rules: =item.text always resolves (or is explicitly null); =item.json.<field> is the structured path; =item.raw preserves today's behavior for anyone who needs the provider blob. This is additive — raw is exactly what nodes emit today — so migration is mechanical.
Keep the crate host-agnostic: the envelope is defined in
vendor/tinyflows(src/data.rs/ node executors). The host adapters (caps.rs) already produce{text}/ parsed JSON, so they map ontojson/textdirectly.
A1. Normalize the agent node output — highest leverage, do first
- Crate (
vendor/tinyflows/src/nodes/integration/agent.rs:115): wrap the completion in the envelope instead ofItem::new(value). Ifoutput_parserran, the coerced value goes injson; the completion text (when present) intext; the untouched response inraw; a model-elected tool result stays underjson.tool_resultand mirrors to a stabletool_resultaccessor (see A2). - Host (
src/openhuman/tinyflows/caps.rsOpenHumanLlm::complete): return{ json: <parsed-or-null>, text: <response.text>, raw: <full response> }rather than either the bare parsed object or the{text}fallback. Removes the runtime shape-flip (audit M1). - Tests: update
agent.rsunit tests +caps.rsseam tests; add an e2e asserting=item.textresolves on both a JSON-emitting and a prose-emitting model (mock both).
A2. Unify inline-tool vs tool_call-node result shape (audit M2)
Make a tool result reachable at the same path whether the tool ran inline in an agent node or as a standalone tool_call node. Standardize on the envelope: tool_call node emits { json: <tool output>, raw: <composio envelope> }; the agent's inline tool result lands at item.json.tool_result and the node envelope's raw keeps the full completion. Document the one canonical accessor.
A3. Per-item execution for integration nodes (audit M3 — the silent-drop trap)
Today agent/tool_call/http_request always emit one item and bind config against input.first() only, so split_out (N) → tool_call fires once and drops N−1 items.
- Add a node config flag
execution: "once" | "per_item"(defaultper_itemfortool_call/http_request; defaultonceforagent, since an agent turn is usually batch-level — but allowper_item). - In
per_itemmode: map the executor overctx.input, re-resolving config per item (so=item.xmeans this item), emit one output item per input, carrypaired_item(vendor/tinyflows/src/data.rs). - Touches
agent.rs,tool_call.rs,http_request.rs, and the per-item resolution path innodes/mod.rs. - Tests:
split_out → tool_callruns N times;paired_itemlineage preserved;oncemode unchanged.
A4. Port-aware collect_input (audit M4 — untaken-branch leak + BUG-3/BUG-4)
collect_input (vendor/tinyflows/src/engine.rs:171) concatenates items from every predecessor slot regardless of Edge.to_port (stored, never read) or which port the predecessor emitted on. A node after a condition reads the not-taken branch's items.
- Read
Edge.to_port/ the predecessor's recordedport; collect only items the predecessor actually emitted on the connecting port. - Enables named merge inputs (input A vs B) and removes the leak.
- Fold in the merge-barrier gap (BUG-4) and mixed-port fan-out drop (BUG-3) from the audit — same routing/lowering surface (
engine.rs:422,789-826). - Tests: condition-false slot not visible to a node wired on the true port; merge fed by a branching predecessor barriers correctly; the
main→a, main→b, error→hshape runs bothaandb.
A5. merge modes (audit M5)
Add merge.mode: append (today's concat), combine_by_key (join items by a key field), combine_by_position (zip). Config-only change in vendor/tinyflows/src/nodes/control_flow/merge.rs; barrier semantics unchanged.
A6. Author-time alignment lint (feeds Part C tooling)
A validation pass that, given a producer node's known/declared output envelope, flags downstream =item.<field> references the producer cannot emit — surfaced as structured, node-addressed diagnostics. Depends on tightening validate.rs (audit BUG-10). Wire into the agent-facing validate / revise_workflow tools so the builder agent gets the feedback.
A-bugs (bundle with the above — from audit §3)
- BUG-1 (security, hotfix now): jq
envbuiltin leaks host env — disablejaq-stddefault features / filterenv/input*invendor/tinyflows/src/expr.rs:302. - BUG-2:
switch/transformdon't get thenodesscope — useexpr_scope(switch.rs:26,transform.rs:27). - BUG-5/6: sub-workflow HITL dropped;
on_run_finishnever fires on failure — verify againstFlowRunObserver. - BUG-9:
code/output_parser/sub_workflowskip=-resolution — make expression binding uniform.
Part B — Selectable agent kinds
B0. The idea
Let an agent node declare which agent runs it, by referencing an OpenHuman registry agent:
{
"kind": "agent",
"config": {
"agent_ref": "code_executor", // or "researcher", "crypto_agent", …
"prompt": "=item.text",
"connection_ref": "…", // still host-resolved, never model-supplied
// optional per-node overrides:
"model": "…",
"max_iterations": 6,
"tools_allow": ["grep", "edit"],
},
}
When agent_ref is set, the host runs that registered agent — with its curated toolset, model hint, sandbox mode, and iteration policy — as a full multi-turn agent loop, instead of the current single provider.chat call. A coding step gets coding tools; a research step gets web_search/web_fetch; a crypto step gets market tools. This is exactly the registry's existing contract (src/openhuman/agent_registry/agents/*/agent.toml).
B1. Crate seam — new AgentRunner capability (host-agnostic)
The crate must not know about OpenHuman's registry, and "run a named agent to completion (multi-turn, tool-using)" is a different capability than LlmProvider.complete (single shot). Add a trait to vendor/tinyflows/src/caps/mod.rs:
#[async_trait]
pub trait AgentRunner: Send + Sync {
/// Run a host-registered agent identified by `agent_ref` to completion.
/// `request` carries prompt/input/overrides; `conn` is the opaque credential.
async fn run_agent(&self, agent_ref: &str, request: Value, conn: Option<&str>) -> Result<Value>;
}
- Add
agent: Option<Arc<dyn AgentRunner>>toCapabilities(optional so hosts without a registry keep working). agent.rsdispatch: ifconfig.agent_refis present andcaps.agentis wired →run_agent(...); else fall back to today'sLlmProvider.completepath. Both emit the A1 envelope, so downstream expressions are identical regardless.- Mock impl in
caps/mock.rs(echoagent_ref+ request) so crate tests exercise both paths. agent_refis trusted config only, never taken from model output (same rule astool_call.connection_ref,agent.rs:71-79).
B2. Host adapter — implement AgentRunner over the registry + delegate runtime
src/openhuman/tinyflows/caps.rs: newOpenHumanAgentRunnerimplementingAgentRunner.run_agent(agent_ref, request, conn):agent_registry::ops::get_agent(agent_ref)→ resolve the entry (tools, model hint, sandbox,max_iterations,iteration_policy).- Apply optional per-node overrides (
model,max_iterations, and a narrowing-onlytools_allow— a node may subset the agent's tools, never add). - Drive the existing delegate runtime (
src/openhuman/agent/tools/delegate.rs) with aTurnOriginof the flow run (src/openhuman/agent/turn_origin.rsalready has a flow origin variant). - Return
{ json, text, raw }(A1 envelope): final structured output injson, final message text intext.
- Autonomy/security: the agent kind's tools are still gated by
SecurityPolicy/ autonomy tier; asandboxedagent (e.g.code_executor) runs sandboxed; the flow's own approval gate still parks outbound actions. No new privilege path. - Depth/cost guard: an agent node running a full agent loop inside a flow can fan out cost — bound it (reuse
MAX_SUB_WORKFLOW_DEPTH-style counter or a per-run agent-invocation cap) and thread cancellation into the delegate run (today's sub-workflow path drops the token — BUG-5 — fix here too).
B3. Authoring surface — ground the choice for the builder agent
- Builder tool
list_agent_profilesinsrc/openhuman/flows/builder_tools.rs, backed byagent_registry::ops::list_agents(false), returning{ id, display_name, when_to_use, tools, sandbox_mode }. Mirrors the existingsearch_tool_catalogpattern so theworkflow_buildersub-agent picks a realagent_refinstead of hallucinating one. - Validation (
vendor/tinyflows/src/validate.rs+ host): anagentnode with anagent_refthat doesn't resolve is a structured validation error (needs the host to pass the known-agent set into validation, or validate host-side inflows::ops::validate). - workflow_builder prompt (
src/openhuman/agent_registry/agents/workflow_builder/prompt.md): documentagent_refand when to prefer a specialized agent over a bare completion.
B4. UI — agent-kind picker
- The (upcoming) node config panel (
U2in the integration plan) gets an Agent kind dropdown foragentnodes, populated fromagent_registrylist_agentsRPC (already exists), showingdisplay_name+when_to_use, with an optional tool-subset multiselect. - Read-only canvas: show the chosen agent kind as a node badge.
Part C — Sequencing & ownership
| Phase | Work | Depends on | Surface |
|---|---|---|---|
| C0 | BUG-1 env-leak hotfix; BUG-2 switch/transform scope | — | vendor/tinyflows/src/expr.rs, control_flow/* |
| C1 | A0 envelope + A1 agent output normalization | C0 | agent.rs, caps.rs |
| C2 | A2 tool-shape unification; A3 per-item execution | C1 | tool_call.rs, http_request.rs, nodes/mod.rs |
| C3 | A4 port-aware collect_input (+ BUG-3/4); A5 merge modes |
— (parallel to C1/C2) | engine.rs, merge.rs |
| C4 | B1 AgentRunner capability + mock |
C1 (shared envelope) | caps/mod.rs, caps/mock.rs, agent.rs |
| C5 | B2 host OpenHumanAgentRunner over registry+delegate (+ BUG-5 cancellation) |
C4 | tinyflows/caps.rs, agent/tools/delegate.rs |
| C6 | B3 list_agent_profiles + agent_ref validation; A6 alignment lint |
C5, BUG-10 | flows/builder_tools.rs, validate.rs |
| C7 | B4 UI agent-kind picker | C5, U2 node panel | app/src/components/flows/* |
Each phase ships with tests (crate unit + host seam + JSON-RPC E2E) per the repo's "tests before the next layer" rule, and records design decisions in vendor/tinyflows/local/docs/11-decisions.md.
Open questions
agent_refin the crate: prefer a dedicatedAgentRunnercapability (B1, recommended — keepsLlmProvider.completea clean single-shot contract) vs. overloadingcompleteto loop when it seesagent_ref. Recommendation: the capability.- Per-node model/tool overrides: allow narrowing only (
tools_allowsubsets the agent's toolset) — never widen, to preserve the registry's security envelope. Confirm this constraint. - Cost/loop bounds for agent-kind nodes inside a flow (and inside a sub-workflow): reuse the depth counter or introduce a per-run agent-invocation budget?
- Custom agents:
upsert_custom_agentalready exists — user-defined agent kinds are addressable byagent_reffor free. Confirm they should be selectable in flows.