picoflow.io Docs

Compare

Tool loops and validation

The direct demo implements more hotel-specific checks, while PicoFlow supplies a reusable Step-level acceptance gate that can reject and regenerate any model result before it has effects.

Tool calling has four separate responsibilities: tell the model which tools exist, validate the arguments, execute domain code, and decide whether to call the model again. Saying that both implementations “use tools” obscures who owns each responsibility.

Tool declaration and dispatch

In PicoFlow, a step returns ToolType[] from defineTool() and marks matching methods with @Tool. At bootstrap the framework validates and registers the definitions. During execution LlmRunner matches model tool calls to decorated handlers, invokes them, creates ToolMessages, applies transitions, and either loops or returns.

The direct implementation creates seven LangChain tools with tool(...) and binds stage- specific arrays to each model. The functions passed to tool(...) simply return their input; the graph never invokes those tool objects. Their practical role is model-facing name, description, and schema metadata. Actual execution is manual branching inside exploreTools, presentTools, and compareTools.

model-facing declaration       runtime execution

captureChoicesTool      --->   if call.name === "capture_choices" ...
chosenHotelTool         --->   if call.name === "chosen_hotel" ...
generateComparisonTool  --->   if call.name === "generate_comparison" ...

That duplication is not required by LangGraph; a direct application could invoke the tool objects or use a prebuilt tool node. It is, however, the architecture of the implementation being measured.

Loop control

PicoFlow tool outcomes form a small control language:

  • go(Step) switches stages and normally continues the model loop;
  • stay(feedback) keeps the current stage and gives corrective tool feedback;
  • direct(content) returns exact content without another model call;
  • .withState(), .withMessage(), .withPrompt(), and .withContentType() add effects.

The direct graph expresses the same decisions through state. route chooses the next agent or end; response carries the eventual HTTP response; phase becomes the durable stage; and message-array updates provide tool feedback.

Multiple tool calls

PicoFlow’s runner walks every returned tool call in order. It also supports an @Tools batch handler when one method must own a multi-call turn. Missing and hallucinated handlers are converted to tool messages and session warnings.

The direct hotel graph deliberately narrows the policy: latestToolCall() chooses terminate_session if present, otherwise the first tool call on the latest AI message. Other parallel tool calls are ignored. This keeps a hotel stage deterministic, but it should be enforced at the model binding level or documented as a contract; otherwise a model that emits two legitimate calls receives only one result.

A PicoFlow advantage: the Step is the final acceptance gate

The validators below measure what the two hotel demos currently implement. They do not capture an additional PicoFlow engine capability. Every model invocation runs inside the shared Retry.goTry() loop. After the empty-response check, LlmRunner passes the complete model result to the active step’s checkResponse() before dispatching tool calls or accepting a normal or structured response.

checkResponse() has intentionally inverted acceptance semantics:

false  -> accept this model result
true   -> reject it and invoke the model again

This makes the step the last responsible authority over model output. Because the step also owns the prompt, domain state, memory namespace, and flow context, its decision can be semantic and state-aware rather than limited to static JSON shape. A step can veto:

  • syntactically valid JSON that violates a business invariant;
  • an incomplete provider-normalized structured result;
  • a prohibited or policy-inconsistent free-form answer;
  • a tool call whose arguments conflict with the current step state; or
  • output that is technically valid but not good enough to advance the flow.

For example, ExploreStep could inspect the raw capture_choices call before its handler runs:

public override checkResponse(result: string | object): boolean {
  const message = result as {
    tool_calls?: Array<{ name: string; args?: Record<string, unknown> }>;
  };
  const call = message.tool_calls?.find(
    ({ name }) => name === "capture_choices",
  );
  if (!call) return false;

  try {
    const criteria = JSON.parse(String(call.args?.json));
    return !isAcceptableCriteria(criteria); // true asks PicoFlow to retry
  } catch {
    return true;
  }
}

The direct HotelLanggraph has no equivalent application-wide response-acceptance hook. A non-tool AI response becomes the turn response and routes to END; tool arguments are checked later inside hand-written tool nodes. LangGraph can implement an equivalent validation node, conditional retry edge, or model-call wrapper, but the application must design its state, routing, retry accounting, and observability. PicoFlow centralizes that policy behind the regular Step contract.

This produces three complementary validation layers in a well-built PicoFlow flow:

Layer Responsibility
Zod tool or structured schema Reject the wrong data shape
Step.checkResponse() Veto a contextually unacceptable model result before it has effects and regenerate it
Tool handler plus stay(reason) Recheck authoritative domain invariants at the side-effect boundary and give explicit corrective feedback

The middle layer is the distinctive PicoFlow advantage. Automatic schema validation answers “does this value have the expected shape?” The step acceptance gate answers “given everything this stage knows right now, is this result worthy of being accepted?”

There are four current caveats. true means retry, which is easy to misread; the hook is synchronous and should be deterministic; rejected attempts still consume and tally model tokens; and the retry currently reuses the same request history without adding a reason-specific correction. Use stay(reason) when the model needs explicit feedback rather than a fresh regeneration. The runtime currently passes the raw AI response object for normal/tool output and the provider-normalized object for structured output, despite the broad string | object signature, so implementations must extract raw message content deliberately. Empty no-tool responses and invocation or structured-parser exceptions take the engine’s own retry path before this hook. The default limit is three total attempts, and rejected output is not appended to step memory. When the step—not the provider wrapper—must decide whether raw JSON is acceptable, it should inspect and parse the response content in checkResponse().

Domain validation implemented in these demos

The direct graph validates considerably more than the PicoFlow demo:

Boundary PicoFlow HotelFlow Direct HotelLanggraph
Whole-response acceptance Framework provides Step.checkResponse() veto and retry, but HotelFlow does not override it No shared acceptance hook in this implementation; non-tool output is accepted and ends the run
capture_choices.json Catches parse failure, then dereferences the possibly undefined result Rejects invalid JSON with tool feedback
Dates Reads start/end and sends them to pricing Requires both, parses both, requires checkout after check-in
Room and amenities values Trusts submitted arrays Filters values against the template’s allowlists
Comparison feature Any string Zod enum: price, room type, amenities, or distance
Hotels to compare Trusts names and may produce undefined rows Deduplicates and rejects names outside current results
Booking Saves the model-provided name Requires an exact member of current results
Confirmation Number exists only inside a generated terminal prompt Persists booked hotel and confirmation number

The direct demo currently implements more hotel-specific checks. Framework-level Zod validation only proves that an argument has the declared shape; it does not prove that a hotel exists, a date range is sensible, or a requested item belongs to the current search. Those invariants still belong in application code. PicoFlow, however, provides the stronger reusable whole-response acceptance boundary; this HotelFlow simply has not used it yet.

Where each can fail

The current PicoFlow HotelFlow does not yet use that acceptance gate. Its capture_choices handler catches a JSON parse error and then dereferences the undefined result; other handlers can accept an invented hotel, an unsupported comparison feature, or build rows containing undefined. These are demo-level validation gaps, not the limit of PicoFlow’s lifecycle. ExploreStep could reject malformed or semantically incomplete output in checkResponse() before dispatch and let the engine regenerate it. Handlers should still enforce authoritative business invariants and return stay(reason) when corrective feedback is more useful than a blind regeneration. If neither layer catches the error, the engine aborts the turn and the user receives a generic failure.

The direct graph has its own gap: most validation failures route back to the model, but unexpected exceptions return HTTP 400 without persisting an error record or aborted status. Its validation also normalizes unsupported room types and amenities by filtering them out; depending on product policy, silently dropping a value may be worse than explicitly rejecting it.

Models and provider boundaries

Both implementations use the same effective stage models: GPT-5.1 with low reasoning effort for explore and compare, and GPT-4o at temperature 0.5 for presentation.

PicoFlow resolves those selections through a shared model catalog and provider adapters. A flow declares its default with configModel(), and steps override with useModel(). The engine validates supported model parameters during registration and records the effective model in the session.

The direct graph constructs ChatOpenAI instances in createOpenAiModel() and binds tools there. This is straightforward and exposes every provider option immediately. Its HotelModelFactory seam is also excellent for deterministic testing. Supporting another provider, standardizing retries, or recording model metadata remains application work unless the team extracts a shared factory.

This is another mixed result: PicoFlow has the stronger portfolio-wide provider contract; the direct implementation has the cleaner application-level model-test seam.

The lesson

PicoFlow reduces execution-loop code without taking the validation decision away from the domain-owning step. Direct LangGraph makes the tool trust boundary visible because the tool node owns it, but that safety came from implementation effort, not from the graph primitive itself. The strongest PicoFlow version combines its native response veto/retry hook with the direct demo’s explicit handler validators: less orchestration, contextual last-moment acceptance, and authoritative checks before side effects.