The planner turns a plain-language task ("book the cheapest flight from
Delhi to Srinagar next Sunday") into a flow: the step-by-step plan the
task controller and flow runtime actually run. It lives in
crates/tinycomputer-engine/src/planner/, and it is optional: a task
started with a flow already written never touches it, and a module built
without one simply cannot run a plain-language task; it pauses with
NeedsPlan instead (see tasks.md).
The one thing worth remembering about the planner: it never acts, and it never sees the screen. It only ever sees the task's text, the flow authoring guide, the names of facts the caller can supply, and which surfaces (desktop, browser, or both) are available. It writes a flow; Jev and the flow runtime are what actually look at anything on screen.
Planner::plan is a short conversation with a LanguageModel:
- A system turn carries the planner's protocol (what it may and may not do)
followed by the flow authoring guide (
FLOW_GUIDE, shared withDescribeand with flow authors generally). - A user turn states the task, which surfaces are available, and which
facts the caller can supply, by name only: split into ordinary facts
(
${first name},${email}) and secret ones (${card number}), never by value. - The model replies with what should be exactly one JSON object: a flow.
- That flow is validated with the very same checker (
agentic::check_flow) a caller's own hand-written flow would be checked with. If it is invalid, the specific errors go back to the model as a fourth turn, asking for a corrected flow, up toREPAIRS(2) times. - If a valid flow never arrives,
planfails with a plain-text reason; the caller'sPlanTask(or the task'sNeedsPlanretry, if planning was attempted insideStartTask) then surfacesPLAN_FAILED.
A successful plan comes back as a TaskPlan: the flow itself, questions
(an InputField per ${name} the flow uses that the caller did not
already name as a fact, so the caller can collect those before starting),
and notes, currently just one: whether the plan stops before something
irreversible or paid (a stop_before step is present).
The planner's protocol is explicit about the boundary. It is told:
- to use
browsefor anything on the web andopenfor a desktop application; - to refer to the person's details only as
${name}variables, using the fact names it was given, and inventing a clear name for anything else the task will need, so the caller can be asked for it; - never to invent a personal detail itself;
- that a shared fact may appear in any step's text, written the way a person would ("choose ${title} in the title field");
- that a secret fact may appear only as an
enterstep's value: never in anopenapplication name, abrowseaddress, ado,verify,wait_for,stop_before,choose,read,extract,pick,repeat_until, oriftext, and never as anenterslot's own name either; - to enter payment details only from secret facts it was actually given,
and to end any purchase or booking with a
stop_beforefor paying; - that a
chooseoption must be the label the page shows ("Saver"), never a description ("the cheapest fare"): picking by a criterion is whatpickis for; - to guard sending, deleting, publishing, or submitting with a
stop_before.
It is never told a fact's value, never shown a screenshot or an accessibility snapshot, and never asked to decide anything at run time. All of that is Jev's job, later, when the flow it wrote is actually run.
pub trait LanguageModel: Send + Sync + 'static {
fn complete(&self, turns: &[Turn]) -> Completion;
}Turn is one message (Role::System, Role::User, or Role::Assistant)
and its text; Completion is a boxed future resolving to the model's reply
text or a failure string. Keeping the model behind this trait is what lets
planner/planner_tests.rs script a fake model's answers deterministically, rather
than every planner test needing a live API call.
The planner Cargo feature adds one concrete LanguageModel:
crates/tinycomputer-engine/src/planner/hosted.rs, built on
tinyinference_llm's OpenAI-compatible client pointed at the configured
route. This is the only file in the crate that links a text-generating model
at all: the keys it is given in the module's private configuration never
leave this one adapter. Every call is streamed and gathered into one reply:
Tiny Humans' gateway answers HTTP 504 to a request that sends nothing back
for 60 seconds, and a reasoning model's whole reply can take longer. A call
that fails in passing (a server error, a rate limit that is not a spending
cap, a dropped connection) is tried up to 4 times, 1, 2, then 4 seconds
apart: live, one 502 from the gateway ended a task at its plan. A refused key
or a rejected request is not tried again. It builds all three of the crate's
language-model helpers, not just the planner:
pub fn open_router(config: &PlannerConfig) -> Result<Planner, String>
pub fn open_router_rescuer(config: &PlannerConfig) -> Result<Rescuer, String>
pub fn open_router_shaper(config: &PlannerConfig) -> Result<Shaper, String>The names predate the second route and are kept; each builds on whichever
route PlannerConfig selects.
PlannerConfig (planner/config.rs) holds a route (ModelRoute,
flattened into the object: api_key, provider, endpoint_url,
sdk_name), an optional model (PLANNER_MODEL,
anthropic/claude-sonnet-5, when unset), an optional rescue_model
(RESCUE_MODEL, openai/gpt-6-luna; see rescue.md), an
optional output_model (OUTPUT_MODEL, also openai/gpt-6-luna; see
output.md), and an optional rescue_route, a complete
ModelRoute of the rescuer's own that inherits nothing from the planner's.
provider is tinycomputer_bus::agent::LanguageModelProvider:
open_router (the default, OPEN_ROUTER_BASE_URL,
https://openrouter.ai/api/v1) or tiny_humans (TINYHUMANS_BASE_URL,
https://api.tinyhumans.ai/openai/v1, with the host's TinyHumans bearer).
endpoint_url may only repeat the selected route's base URL, the same
allow-list principle as Jev's endpoint; sdk_name becomes an x-sdk-name
header on the Tiny Humans route only.
All three are chat completions with different settings: the planner's own
model is asked for a JSON object response format at a low sampling
temperature (0.2), because a plan should be reproducible rather than
creative, while the rescuer and the shaper are reasoning models given a
little room to think before they answer. Each reports its route and model
(LanguageModelConfiguration) through configuration(), which Describe
serves as planner_model, rescue_model, and output_model.
| Code | Cause |
|---|---|
PLANNER_NOT_CONFIGURED |
PlanTask was called on a module with no planner. |
PLAN_FAILED |
The model failed outright, or its answer stayed invalid after every repair. |
Both hints point the same direction: reword the task, or write the flow by
hand from Describe's guide and pass it as flow instead of task.
crates/tinycomputer-engine/src/planner/mod.rs,Planner, the protocol text,plan_for(turning a flow into aTaskPlan).crates/tinycomputer-engine/src/planner/config.rs,PlannerConfig,ModelRoute, the model defaults, and the route allow-list.crates/tinycomputer-engine/src/planner/hosted.rs, the hostedLanguageModel,open_router,open_router_rescuer,open_router_shaper.crates/tinycomputer-engine/src/agentic/flow/,check_flowandmissing_inputs, which the planner reuses unchanged.- writing-flows.md, the flow language the planner (and every human flow author) writes in.
- rescue.md and output.md, the other two language-model helpers this same adapter builds.