Documentation
¶
Overview ¶
Package runtime is the playground's in-app side (WEFT-PLAYGROUND.md §10.2, [D6]): it registers the agents your code built with the Studio the app is already observed by, receives experiment commands over the runtime link, and executes them as real runs of those agents — your tools, your model keys, your process. Studio never runs your agent; it asks, this package answers.
The whole integration is one deferred call:
defer runtime.Install(
runtime.Studio(url, token), // or runtime.Local(srv) in setup A
runtime.Agents(support, billing), // the agents a runtime exposes
runtime.Models(map[string]core.Model{ // allowed alternates, by display name
"glm-5.3-flash": glmFlash,
}),
runtime.Limits(runtime.Budget{MaxTokensPerExperiment: 200_000, MaxRunsPerExperiment: 60}),
runtime.AllowSideEffects("send_email"), // real only under side_effects "allow"
runtime.Threads(store), // thread.Storage; nil = ephemeral only
)()
Safety (WEFT-PLAYGROUND.md §6, non-negotiable) ¶
Without Install no link opens, and even then only when WEFT_ENV=dev or Enabled(true) is set — production binaries expose nothing unless they opt in explicitly. Commands can only narrow: tools the agent registered can be turned off, never added (OnlyTools); models come from the Models allow-list or the app's own ModelResolver; MaxSteps and Parallelism only lower; park_on only adds parking; sampling params and the tool choice are neutral (a named choice must name a tool the command keeps on and does not park). Side effects never re-fire silently (§6 rule 3): a tool counts as "never" unless its code vouched core.Replay(core.ReplaySafe) — a vouched tool runs in every mode. A tool the runtime opted in with AllowSideEffects runs for real only when the command asks for side_effects "allow"; in the other modes it is a side effect like any other. In side_effects "substitute" (the default), a parked call that matches a recorded call of the source (same tool, same arguments as JSON; repeated calls in the order the source made them) is answered with the recorded result — the runtime acts as ADR 0007's resolver, the handler never runs; a miss stays parked for a human (the panel's continue / skip / resolve). "park" answers nothing from the record: every such call waits at the boundary. "allow" runs the opted-in tools for real, and is refused unless every tool the command leaves on is opted in or vouched ReplaySafe. A parked run resumes once each of its parked calls has a decision, under all of them; a decision naming a call that is not parked is rejected. The debugger's breakpoints (§8.3) park their tools on every run this package starts, and steer (§8.4) delivers into a run it holds: an ephemeral run's steering queue, or a fork turn's session as a thread steer under that turn's run options — a steer thread defers to a follow-up turn keeps the park rule. The app's own runs are the app's to opt in (PQ7, plan F4): breakpoints park their tools on the app's own runs of an agent built with Breakpoints() (core.ParkSource, ADR 0030 — read once per run, inert without a live link), and the app's turns take decisions and steers only when the app installed Sessions — through the app's own Session, under its writer lease. A plain Generate parked by a breakpoint is the app's to resume (weft.Approve); Studio decides session turns only.
The rule is default-deny (core.ParkAllExcept): a run lists the tools that may execute — the vouched-safe ones, an Output agent's submit_output, and under "allow" the opted-in names — and every other call parks, matched by name against each step's own tool set. So a tool that reaches the run only through core.ToolSource parks like any unannotated tool, and the rule follows a core.Subagent delegation into the child run: the child's own unvouched tools park there. One limit: a child's parked call is not the panel's to decide — the delegating call reads SUBAGENT_PENDING (ADR 0014) and the parent run carries on, the side effect never having fired. Names are matched in parent and child alike, so opt a name in only if every tool of that name down the delegation may run.
Everything a command carries is re-validated here, whatever Studio checked: unknown agents, tools, models, modes and options are rejected, never defaulted; the source run id, from_step and the transcript edits are checked against the transcript this runtime resolved itself. The link holds bounded state — at most 256 commands admitted and 16 runs executing at once, the newest 4096 command ids for at-most-once, 128 parked runs, 64 forks — and stopping it cancels the runs it started.
The engines: "live" runs the agent's own model (or a Models alternate); "scripted" (§5.5) answers each model call with the source run's recorded turn at zero tokens — its own core.Model over the messages records, keyed like wefttest's fixtures and missing loudly ("no recorded turn") when the input changed, never silently answering a prompt experiment. Thread modes: "ephemeral" (nothing written to thread storage) and "fork" (§5.4) — the source session opens read-side, Fork copies it to a new session with lineage (thread mints the run ids, stamps weft.session.forked_from), and the command's input becomes the fork's next turn under the same shaping; the source's approval grants are revoked in the fork (the app user's standing consent does not decide an experiment's parked call); a later fork command naming the fork's latest turn continues it in place, one naming an earlier turn forks from that turn. A fork's parked call is the fork session's own approval boundary: a decision is recorded in the fork and resumes it as the fork's next turn (the record is never consulted in a fork — every such call parks).
Runs this package starts are ordinary weft runs: they flow through the weft/otel pipeline to every destination, carrying weft.playground = true, weft.playground.command, weft.experiment.id and weft.forked_from, and never weft.session.id in ephemeral mode (an experiment is not a turn of the session). Per-experiment budget caps (Limits) are counted from each command's own usage; a breach rejects the next command of that experiment and never touches the app's own runs.
Where it sits ¶
A module of its own because it imports thread (fork mode, transcript reads) and carries an executor, a registry and budget accounting — none of which belongs in the exporter-wiring module weft/otel (review §1.8). Arrows point down only: this package imports root, thread, obsdb, otel and studio (studio for *studio.Server alone, in runtime.Local); nothing imports it back.
Index ¶
- func Breakpoints() core.Option
- func Install(opts ...Option) (shutdown func())
- type Budget
- type Option
- func Agents(agents ...*core.Agent) Option
- func AllowSideEffects(tools ...string) Option
- func Enabled(on bool) Option
- func Limits(b Budget) Option
- func Local(srv *studio.Server) Option
- func ModelResolver(resolve func(ctx context.Context, name string) (core.Model, error)) Option
- func Models(models map[string]core.Model) Option
- func Sessions(resolve SessionResolver) Option
- func Studio(url, token string) Option
- func Threads(store thread.Storage) Option
- type SessionResolver
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Breakpoints ¶ added in v0.15.0
Breakpoints makes the debugger's breakpoints (§8.3) park tools on the runs the app starts itself — plain Generate/Stream calls and weft/ thread turns alike — not only on the runs this package starts (plan F4, PQ7). Pass it to core.New (weft.New) with the agent's other options:
agt := weft.New(model, tools…, runtime.Breakpoints()) defer runtime.Install(runtime.Agents(agt), runtime.Sessions(resolve))()
It is a core.ParkSource (ADR 0030) bound to the process's runtime link lazily: the agent is built before Install runs, so each run reads, once at its start, the breakpoint set of the links Install has started and not yet stopped — the set Studio's PUT /api/runtimes/{id}/breakpoints replaces and re-sends at every registration. One link per process is the rule; should several be live, a run parks on the union of their sets. A call to a breakpoint tool parks at the approval boundary (ADR 0007) as a RequireApproval tool's does: its ToolStart and no ToolFinish, the run ending successfully with the call on RunResult.Pending. In a Subagent's child run (which inherits the parent's context) the child parks and the parent's call to it is a SUBAGENT_PENDING result the parent model sees and continues past — core's rule for any park in a child, not a park of the parent. Under a thread turn with Sessions and a public id, Studio accepts a decision on such a child's run (it inherits the session id and public id) and answers 202, but the runtime then rejects it ("no parked call": the session's Pending is empty, the parent turn having completed) — the child's park is decided by nobody, and the parent has continued past it.
Who decides a parked app run:
- a thread turn (weft/thread) parks on a breakpoint only when this process set runtime.Sessions and the session has a public id (thread.PublicID) — Studio decides by public id, and a park nobody could decide would hold the session (every later Send queues behind it, ADR 0021 §5) until the app decides it; without both the source answers nothing for session runs (those carrying weft.session.id). With both the debugger's approve / deny / resolve reaches the turn through the app's own Session (Decide), which resumes it;
- a plain Generate ends with res.Pending, and only the app can resume it (Generate again with Messages(res.Messages...) and weft.Approve / Deny / Resolve): Studio decides session turns only (Sessions resolves by public id), so the run page shows such a run parked, read-only.
The set is process-wide: every agent built with the option parks on it, whether or not it was given to Agents — the registration's breakpoints_own reports only the agents given to Agents. A link whose stream dropped but that was not stopped keeps its last set: the app's runs keep parking on it until the link reconnects and Studio re-sends the set (or the link stops), and a decision reaches a parked turn once it reconnects. A link STOPPED while a turn is already parked leaves that park with no decider until the app decides it or a link with Sessions comes back: the park set is read once per run, at its start, and the stop does not unpark what it parked. The runs this runtime starts itself — playground runs and the runs started inside them, which carry weft.playground metadata — are left to their own breakpoint rule (a ParkOn filtered to the agent's tools, on their override fingerprint): the source answers nothing for them.
The option is inert without a link: with no Install, or Install not enabled (WEFT_ENV not dev, no Enabled(true)), or a link stopped, or no breakpoint set, the source answers nothing and the run is byte-identical to one built without it. The registration reports breakpoints_own: true when any agent given to Agents carries it.
Example ¶
ExampleBreakpoints builds the agent with runtime.Breakpoints() so the debugger's breakpoints park tools on the app's own runs too — here inert: with no link open (Enabled(false)) there is no breakpoint set, and the run is exactly what it would be without the option.
package main
import (
"context"
"fmt"
"github.com/weftgo/weft"
"github.com/weftgo/weft/runtime"
"github.com/weftgo/weft/wefttest"
)
func main() {
refund := weft.Tool("refund", "Refund an order.", func(ctx context.Context, in struct{}) (string, error) {
return "refunded", nil
})
support := weft.New(
wefttest.Script(wefttest.ToolCalls(wefttest.Call{Name: "refund"}), wefttest.Say("Refunded.")),
weft.Name("acme-support"),
refund,
runtime.Breakpoints(), // the debugger's breakpoints reach this agent's own runs
)
shutdown := runtime.Install(runtime.Agents(support), runtime.Enabled(false))
defer shutdown()
res, err := support.Generate(context.Background(), weft.Prompt("Refund order 4411."))
if err != nil {
return
}
// With a breakpoint on refund, res.Pending would hold the call: a
// thread turn is decided from the panel (runtime.Sessions), a plain
// Generate by the app — Generate again with weft.Approve(id).
fmt.Println(res.Text(), len(res.Pending))
}
Output: Refunded. 0
func Install ¶
func Install(opts ...Option) (shutdown func())
Install registers the agents with a Studio and starts the runtime link: it dials out (never listens), so it works behind NAT. It never panics and never fails the program — a runtime that cannot be built (nothing enabled, no endpoint, no named agents) logs a WARN and opens nothing. The returned function stops the link — the command stream ends, the runs the link started are canceled, and it waits (bounded) for both; call it on exit, like otel.Install's. Calling it again is harmless.
The link registers on connect and after every reconnect, receives commands over an SSE stream (resuming with Last-Event-ID), acks every command before executing it (at-most-once: a repeated command id is ignored), executes it as a run of the named agent, and reports the run's end with a finished ack. The run's content flows back through the normal OTel pipeline, not through the link.
Example ¶
ExampleInstall is the whole integration: one deferred call in main. Enabled(false) keeps the example inert — in a real dev binary the link opens because WEFT_ENV=dev (or Enabled(true)).
package main
import (
"fmt"
"github.com/weftgo/weft"
"github.com/weftgo/weft/runtime"
"github.com/weftgo/weft/wefttest"
)
func main() {
support := weft.New(
wefttest.Script(wefttest.Say("Your order shipped yesterday.")),
weft.Name("acme-support"),
)
glmFlash := wefttest.Script(wefttest.Say("…"))
shutdown := runtime.Install(
runtime.Studio("http://127.0.0.1:7331", ""),
runtime.Agents(support),
runtime.Models(map[string]weft.Model{"glm-5.3-flash": glmFlash}),
runtime.Limits(runtime.Budget{MaxTokensPerExperiment: 200_000, MaxRunsPerExperiment: 60}),
runtime.AllowSideEffects("send_email"), // for real only under side_effects "allow"
runtime.Enabled(false), // dev-only by default: WEFT_ENV=dev
)
defer shutdown()
fmt.Println("link:", "closed")
}
Output: link: closed
Types ¶
type Budget ¶
type Budget struct {
MaxTokensPerExperiment int64 // input + output tokens
MaxRunsPerExperiment int64
}
Budget caps one experiment, counted per experiment_id from the usage of the commands this runtime ran (§6 rule 6). Zero fields are no caps. A breach rejects the *next* command of that experiment (budget_exceeded) — never a run in flight, never the app's own runs.
type Option ¶
type Option func(*config)
Option configures Install.
func Agents ¶
Agents registers the agents this runtime exposes. Only registered agents are playable (WEFT-PLAYGROUND.md §5.2). An agent without a name (core.Name) cannot register — its manifest would be nameless — and is skipped with a WARN.
func AllowSideEffects ¶
AllowSideEffects opts tools in to really running in playground runs whose command asks for side_effects "allow" (WEFT-PLAYGROUND §5.1, §6 rule 3): under "allow" a tool named here is never parked and never substituted — its handler executes whenever the experiment's model calls it. In "substitute" (the default) and "park" an opted-in tool is a side effect like any other: a call matching one the source run recorded is answered with the recorded result, any other call parks at the approval boundary ("park" answers nothing from the record). A tool marked core.Replay(core.ReplaySafe) is not a side effect and runs in every mode, opted in or not. A command that asks for "allow" is refused unless every tool it leaves on is named here or vouched ReplaySafe — "allow" runs this list for real, it does not widen it.
Name a tool here only when re-running it is harmless. Names match wherever the run reaches: a tool a core.ToolSource supplies under that name, and a tool of that name in a core.Subagent child (the child is under the same rule — its other tools park).
func Enabled ¶
Enabled opens (or forbids) the link explicitly. The default — no Enabled option — is on only when WEFT_ENV=dev: production binaries expose the runtime link only by explicit opt-in (§6 rule 1).
func Limits ¶
Limits sets this runtime's per-experiment caps. Without it, a runtime accepts unlimited playground runs within the agent's own budgets (MaxSteps, UsageLimit).
func Local ¶
Local takes the embedded Studio server (setup A): the link talks to srv's handler in-process, no socket. srv is the studio.New(...) server the app mounts; nil is ignored. In-process is not exempt from srv's own token gate: a server built with studio.Token needs the token here as well — add Studio("", token).
func ModelResolver ¶ added in v0.13.0
ModelResolver lets a command name a model the runtime did not list in Models — "try this on claude-haiku-4-5" without pre-registering every model. A command's model override that is neither the agent's own model name nor a Models name is handed to resolve while the command is validated, before its ack: a model back runs the command (the run's RunStart.Model and weft.override.model name it); an error rejects the command with "model <name>: <err.Error()>".
resolve is the app's code, so the playground still only narrows: Studio proposes a name, the app decides whether it exists — build the client from the app's own credentials, refuse names it does not support. The error text is shown to whoever ran the experiment, so it must carry no key, URL or secret; the app controls the message. resolve may be called concurrently and once per command; cache clients if building one is costly. Each call is bounded: its ctx is canceled after 10 s and the command is rejected with "model <name>: resolver timed out" (the command runs before its ack, and an ack Studio waits on too long is marked lost); a resolver that ignores ctx is abandoned at the bound and its late result dropped — honour ctx, or the abandoned call keeps running. Registration reports the flag (resolver: true), and Studio accepts an unlisted name only then. A nil resolve is ignored.
Example ¶
ExampleModelResolver lets the playground try a model the app never listed in runtime.Models: a command's provider-qualified name reaches the resolver before its ack, and the app decides whether it exists — it builds the client from its own credentials (here a scripted model stands in for anthropic.New) and refuses everything else. The error text is the rejected command's reason, so it names no key or URL.
package main
import (
"context"
"errors"
"fmt"
"strings"
"github.com/weftgo/weft"
"github.com/weftgo/weft/runtime"
"github.com/weftgo/weft/wefttest"
)
func main() {
support := weft.New(wefttest.Script(wefttest.Say("ok")), weft.Name("acme-support"))
resolve := func(ctx context.Context, name string) (weft.Model, error) {
provider, model, ok := strings.Cut(name, "/")
if !ok || provider != "anthropic" || !strings.HasPrefix(model, "claude-") {
return nil, errors.New("this app serves anthropic/claude-* models only")
}
return wefttest.Script(wefttest.Say("…")), nil // anthropic.New(anthropic.Model(model)) in a real app
}
shutdown := runtime.Install(
runtime.Agents(support),
runtime.ModelResolver(resolve),
runtime.Enabled(false), // dev-only by default: WEFT_ENV=dev
)
defer shutdown()
_, err := resolve(context.Background(), "openai/gpt-9")
fmt.Println(err)
}
Output: this app serves anthropic/claude-* models only
func Models ¶
Models declares the model alternates a command may switch to, by display name — the allow-list the playground's model picker reads and the resolver that turns a command's model string back into a core.Model (§5.2). A name missing here is refused: the playground cannot add a model, only choose among the ones the code registered.
func Sessions ¶ added in v0.15.0
func Sessions(resolve SessionResolver) Option
Sessions makes the app's own turns targets for the debugger's write verbs (plan F4): Studio's approvals and steer (cancel is refused) on a run the app started — a turn of a weft/thread session carrying a public id (thread.PublicID) — reach this runtime as commands, and the runtime acts through the Session resolve answers:
- a decision is the session's own Decide (thread.Approve, Deny or Resolve), which resumes the turn under thread's rules (the parked turn's Next) when the session auto-resumes; under thread.AutoResume(false) the app's Resume does — the resumed run is the app's run, with its own weft.session.id and weft.turn, never a playground run;
- a steer is delivered only into the run it names, while that run is the turn running now (thread.Session.Steer with the command's run id; thread.ErrNotRunning otherwise — no turn running, or a newer one — and the command finishes failed "no running turn to steer for run <id> (…)" naming both runs, nothing written); a delivered steer that misses its drain point (StopWhen, a park, the run ending first) becomes a follow-up turn under ADR 0019's own rule;
- cancel is refused "cancel is not supported on an app turn": thread has no per-turn cancel (Interrupt replaces the turn with a message), and the runtime does not invent one.
The writer lease is law (thread's T1): the runtime claims the Session's lease (Session.Claim) before it acks, so a Session that cannot write — another writer holds the session — is refused "the app is writing this session; fork instead" and nothing is written; a stale one is released and resolved once more. The claim is bounded like the resolve (10 s, then refused "session claim timed out"). A session that requires signed decisions (thread.RequireSigned; Session.RequiresSigned) is refused before the ack, "this session requires signed decisions": the runtime decides unsigned only, and the app decides it with DecideSigned. A deny without a reason reaches the model as "skipped from the devtools panel", the runtime-started runs' words.
Opt-in: without Sessions the app's turns stay viewer-only, the registration says sessions: false, and nothing about the app's runs changes. Each resolve call is bounded like ModelResolver's (10 s, then refused "session resolver timed out"; a late answer is released); honour ctx. A nil resolve is ignored.
Example ¶
ExampleSessions makes the app's own turns targets for the debugger: approvals, steer and cancel on a turn of a session the app created with thread.PublicID reach the runtime, which acts through the Session the resolver answers. Here the app hands its own live Session (no release: the app keeps writing it); an app that keeps none in memory opens one from its store and returns its Close — the runtime claims the writer lease before it acks, so while the app's own writer holds the session the command is refused "the app is writing this session; fork instead" and nothing is written.
package main
import (
"context"
"fmt"
"github.com/weftgo/weft"
"github.com/weftgo/weft/runtime"
"github.com/weftgo/weft/thread"
"github.com/weftgo/weft/wefttest"
)
func main() {
ctx := context.Background()
support := weft.New(wefttest.Script(wefttest.Say("ok")), weft.Name("acme-support"))
live := map[string]*thread.Session{} // the app's open sessions, by public id
s, _ := thread.Create(ctx, thread.Memory(), support, thread.PublicID("pub_7Hk2"))
defer func() { _ = s.Close(ctx) }()
live["pub_7Hk2"] = s
resolve := func(ctx context.Context, publicID string) (*thread.Session, func(), error) {
if s, ok := live[publicID]; ok {
return s, nil, nil
}
return nil, nil, nil // not this app's: refused "no session for this public id"
}
shutdown := runtime.Install(
runtime.Agents(support),
runtime.Sessions(resolve),
runtime.Enabled(false), // dev-only by default: WEFT_ENV=dev
)
defer shutdown()
got, _, _ := resolve(ctx, "pub_7Hk2")
fmt.Println(got == s)
}
Output: true
func Studio ¶
Studio dials the Studio at url with token (setups B and C: the local binary or the hosted one). Empty token is the dev mode of setup B. Without a Studio or Local option, Install falls back to the pipeline's own Studio destination — otel.StudioEndpoint() — then, when that is empty, to the discovery file a running `weft studio` / `weft dev` wrote (unless WEFT_STUDIO_URL is set or WEFT_DISCOVERY=off; a stale file is never trusted), and opens nothing when there is none.
type SessionResolver ¶ added in v0.15.0
type SessionResolver func(ctx context.Context, publicID string) (s *thread.Session, release func(), err error)
SessionResolver answers the app's Session behind a public id — the one a debugger's decision, steer or cancel on one of the app's own turns acts through (Sessions). It returns the app's own live Session value (the runtime then acts through the app's writer: no lease question arises) or one it opens from its store, with release closing what it opened (nil when there is nothing to close; the runtime calls it once the command is done). (nil, nil, nil) says the public id is not this app's: the command is refused "no session for this public id".
Return the app's LIVE Session when it has one. An opened one works, with two consequences: the runtime holds its writer lease through the whole resumed turn (release is its Close, which drains that turn first; the app's own writes to the session fail ErrLocked meanwhile), and the resumed run's context carries none of the app's values — it is the runtime's, detached from the command — so anything the app puts on the context of its own turns (loggers, tenancy, deadlines) is absent from that one.
An error is the refusal's reason: thread.ErrLocked (wrapped or not) is refused "the app is writing this session; fork instead", and thread.ErrStale makes the runtime release and ask once more. Any other error's text reaches whoever acted, so it must carry no secret.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
examples
|
|
|
local
command
Command local is setup A's playground in one process: an embedded Studio on loopback, the app's own pipeline exporting into it, one agent with a scripted model and two tools, and the runtime link — WEFT-PLAYGROUND.md §7's P0 slice, drivable with curl.
|
Command local is setup A's playground in one process: an embedded Studio on loopback, the app's own pipeline exporting into it, one agent with a scripted model and two tools, and the runtime link — WEFT-PLAYGROUND.md §7's P0 slice, drivable with curl. |