Documentation
¶
Overview ¶
Package pool provides bounded concurrent child runs for thread sessions (ADR 0022): one process-wide semaphore with FIFO fairness, subagent tools whose children hold a slot, child sessions linked to the parent session and call, and receipts recording every delegation's journey. The pool imports the root module and weft/thread, never a persistence module (ADR 0011 §1's module rule, one level down), and changes nothing in the core: it is a tool middleware, session entries, and pool-owned goroutines.
Index ¶
- Variables
- type Option
- type Pool
- func (p *Pool) Cancel(receiptID string) error
- func (p *Pool) Close(ctx context.Context) error
- func (p *Pool) Decide(ctx context.Context, parent *thread.Session, ds ...thread.Decision) (*thread.Turn, error)
- func (p *Pool) Forward(ctx context.Context, receiptID string, msg weft.Message) (*thread.Turn, error)
- func (p *Pool) Register(sessionID string, agent *weft.Agent)
- func (p *Pool) Submit(ctx context.Context, parent *thread.Session, agent *weft.Agent, prompt string) (*Receipt, error)
- func (p *Pool) Wrap(name, description string, agent *weft.Agent, opts ...WrapOption) *weft.ToolDef
- type Receipt
- type WrapOption
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrClosed = errors.New("thread/pool: pool is closed")
ErrClosed is returned by Submit on a pool whose Close has run: an accepted delegation outlives the pool that would run it, so a closing pool takes no new work.
var ErrNotRunning = errors.New("thread/pool: receipt is not running")
ErrNotRunning is returned by Cancel and Forward for a receipt no running child answers to — settled before the call, parked at an approval (its fate is the decision, not a cancellation or a steer), or never this pool's (a receipt id from another process is not distinguishable from a settled one, and all refuse the same way).
Functions ¶
This section is empty.
Types ¶
type Option ¶
type Option interface {
// contains filtered or unexported methods
}
An Option configures a Pool at New.
type Pool ¶
type Pool struct {
// contains filtered or unexported fields
}
Pool is a process-wide bound on concurrent child runs (ADR 0022 §1, D2): every child a pool starts — wrapped or submitted — holds one of its max slots for the duration of the child's run. Admission is FIFO (the semaphore's wait queue); a session's fan-out is already bounded by its agent's Parallelism, so the pool adds no per-session quota — the process bound is the one knob. A Pool is safe for concurrent use. The zero value is not usable; New constructs.
func New ¶
New returns a pool admitting at most max concurrent child runs. The bound is the pool's reason to exist, so max below 1 panics — an unbounded pool is no pool (DeerFlow's max_running, the field's evidence for the shape).
func (*Pool) Cancel ¶
Cancel cancels the child running for receiptID — the one explicit cancellation an async child has (D4). The child's session records the canceled turn and the receipt settles canceled; a receipt that is queued but not yet running is dequeued the same way. A receipt with no running child — settled, parked at an approval, or never this pool's — fails with ErrNotRunning: a parked child's fate is the decision that resumes it, not a cancellation.
func (*Pool) Close ¶
Close stops taking work and drains: every running child is canceled — async ones on their pool-owned contexts, sync ones through their registered cancels, their turns settling with their parents — and Close waits for the pool's own goroutines to settle their receipts, every child's session recording its final state before the process gives up the storage (DeerFlow's gateway drain). ctx bounds the wait; its error returns with the drain still progressing. A second Close returns nil — the drain is already done or underway.
func (*Pool) Decide ¶
func (p *Pool) Decide(ctx context.Context, parent *thread.Session, ds ...thread.Decision) (*thread.Turn, error)
Decide records decisions over a parent session's nested approvals and resumes the children they address (ADR 0022 §7): decisions are recorded in the parent — validation, audit and the signed challenge all live there — then the pool replays them into the child session, which resumes exactly as a top-level session does (ADR 0021 §1). When the child runs to its end the delegation completes: a sync one's parked wrapper call resolves with the child's answer (the core's Resolve — re-running the delegation would replay the child), an async one's receipt settles. A child that parks again on the way mirrors its new requests and waits for the next Decide.
With no decisions it is a pure pump: children whose every mirrored request now has an effective decision — or has lapsed past its expiry — resume, which is how a signed DecideSigned on the session drives children too.
The returned Turn is the resumed child's, when the call resumed one; the parent's own continuation is reached through its parked boundary as always. The wrapper call never takes a direct decision: Pending shows the child's requests, and the wrapper completes through them.
Example ¶
A nested approval, end to end (ADR 0022 §7): the wrapped child parks at its gated tool, the parent's delegating call parks with it, the child's request surfaces on the parent's Pending with its lineage, and one decision through the pool resumes the child and completes the parent's call with the child's answer — the parent model reads it as the tool result and finishes its turn.
package main
import (
"context"
"fmt"
"github.com/weftgo/weft"
"github.com/weftgo/weft/thread"
"github.com/weftgo/weft/thread/pool"
"github.com/weftgo/weft/wefttest"
)
func main() {
ctx := context.Background()
gated := weft.Tool("refund", "", func(_ context.Context, in struct{ OrderID string }) (string, error) {
return "refunded " + in.OrderID, nil
}, weft.RequireApproval())
child := weft.New(wefttest.Script(
wefttest.ToolCalls(wefttest.Call{Name: "refund", Args: `{"order_id":"42"}`}),
wefttest.Say("refund issued"),
), gated)
p := pool.New(2)
parent := weft.New(wefttest.Script(
wefttest.ToolCalls(wefttest.Call{Name: "research",
Args: wefttest.Args(struct{ Prompt string }{"refund order 42"})}),
wefttest.Say("handled"),
), p.Wrap("research", "delegates the refund flow", child))
s, _ := thread.Create(ctx, thread.Memory(), parent)
t1, _ := s.Send(ctx, weft.User("refund order 42"))
if _, err := t1.Wait(); err != nil {
panic(err)
}
pend := s.Pending()
fmt.Println("pending:", pend[0].Tool)
childTurn, err := p.Decide(ctx, s, thread.Approve(pend[0].CallID))
if err != nil {
panic(err)
}
if _, err := childTurn.Wait(); err != nil {
panic(err)
}
res, err := t1.Next().Wait() // the parent's parked run, resumed with the answer
if err != nil {
panic(err)
}
fmt.Println("parent:", res.Text())
for _, r := range pool.Receipts(s) {
fmt.Println("receipt:", r.State, "-", r.Stop)
}
}
Output: pending: refund parent: handled receipt: done - refund issued
func (*Pool) Forward ¶
func (p *Pool) Forward(ctx context.Context, receiptID string, msg weft.Message) (*thread.Turn, error)
Forward steers a running child with msg, explicitly (ADR 0022 §8, ADR 0019 §7): the message is sent to the child's session under the Steer policy — delivered at the run's next steering drain point, or deferred to a follow-up turn when the run ends on an intended stop or an open approval boundary; the child's session records the receipt either way. Nothing is ever forwarded implicitly. The returned Turn is the steer's receipt turn; a child that is not running (queued, parked awaiting decisions, settled) refuses with ErrNotRunning.
func (*Pool) Register ¶
Register re-attaches an agent to a child session the process no longer holds one for — the restart hook (ADR 0022 §7): a parked child's boundary outlives the process, and resuming it needs the agent that runs it, which no file carries. Children a Wrap made need no call: the wrap's name, recorded in the child's header metadata, is looked up among the pool's wrapped agents. Register is for the rest — Submit children — and for wraps renamed between runs. A nil agent is ignored.
func (*Pool) Submit ¶
func (p *Pool) Submit(ctx context.Context, parent *thread.Session, agent *weft.Agent, prompt string) (*Receipt, error)
Submit hands a task to a child session of parent and returns at once (ADR 0022 §2): the child session is created in the parent's storage with a lineage naming the parent, the acceptance receipt is durable in the parent session before Submit returns, and the child runs on a pool-owned context — independent of the submitting caller's cancellation by design (D4), canceled explicitly by Cancel or Close. agent must not be nil; the receipt's Child names the session to watch (the v0.4 Watcher) or reopen.
Example ¶
An async delegation: Submit returns at once with the acceptance, the child runs on the pool's context, and Close drains — every child settled before it returns.
package main
import (
"context"
"fmt"
"time"
"github.com/weftgo/weft"
"github.com/weftgo/weft/thread"
"github.com/weftgo/weft/thread/pool"
"github.com/weftgo/weft/wefttest"
)
func main() {
ctx := context.Background()
s, _ := thread.Create(ctx, thread.Memory(), weft.New(wefttest.Script()))
p := pool.New(1)
child := weft.New(wefttest.Script(wefttest.Say("background answer")))
r, err := p.Submit(ctx, s, child, "work in the background")
if err != nil {
panic(err)
}
fmt.Println("accepted:", r.State)
// The child settles on the pool's goroutine; a live application
// reads the receipt back — polled here, bounded for the example's
// sake — and closes the pool only to drain what is still flying.
var final pool.Receipt
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); {
for _, rc := range pool.Receipts(s) {
if rc.ID == r.ID {
final = rc
}
}
if final.State == thread.PoolDone {
break
}
time.Sleep(time.Millisecond)
}
if err := p.Close(ctx); err != nil {
panic(err)
}
fmt.Println("settled:", final.State, "-", final.Stop)
}
Output: accepted: accepted settled: done - background answer
func (*Pool) Wrap ¶
Wrap returns a subagent tool whose child runs hold one pool slot (ADR 0022 §1–§2): a delegation tool over agent, built on the core's Subagent, run by the pool as a child session of the session whose run is calling it — linked by lineage, receipted in the parent, and bounded by the semaphore.
Inside a session's run (the parent found through the run context) the child is a session of its own in the parent's storage: sync by default — the call waits and the result is the child's answer; with Async the result is the receipt and the child runs on. A wrapped tool called outside any session — a bare Generate — has no parent to receipt into and falls back to the ordinary subagent path under the slot, ADR 0014's semantics unchanged.
A delegation chain deeper than the pool's max is refused with the core's SUBAGENT_CYCLE code before any child starts: every sync level holds a slot while it waits, so a chain of max+1 could only deadlock — the pool's bound doubles as the depth guard the core deliberately left unset (ADR 0014).
Example ¶
A sync delegation through a session: the wrapped tool's child runs as a child session under a pool slot, the call waits, the result is the child's answer, and the parent's ledger carries the child's cost in its Delegated bucket.
package main
import (
"context"
"fmt"
"github.com/weftgo/weft"
"github.com/weftgo/weft/thread"
"github.com/weftgo/weft/thread/pool"
"github.com/weftgo/weft/wefttest"
)
func main() {
ctx := context.Background()
st := thread.Memory()
child := weft.New(wefttest.Script(wefttest.Say("the answer is 42")))
p := pool.New(2)
parent := weft.New(wefttest.Script(
wefttest.ToolCalls(wefttest.Call{Name: "research",
Args: wefttest.Args(struct{ Prompt string }{"find the answer"})}),
wefttest.Say("done"),
), p.Wrap("research", "delegates research to a child session", child))
s, _ := thread.Create(ctx, st, parent)
turn, err := s.Send(ctx, weft.User("what is the answer?"))
if err != nil {
panic(err)
}
res, err := turn.Wait()
if err != nil {
panic(err)
}
fmt.Println("reply:", res.Text())
for _, r := range pool.Receipts(s) {
fmt.Println("receipt:", r.State, "-", r.Stop)
}
fmt.Println("delegated output tokens:", s.Usage().Delegated.OutputTokens)
}
Output: reply: done receipt: done - the answer is 42 delegated output tokens: 5
type Receipt ¶
Receipt is a delegation's handle and what is known of it: the acceptance entry's id, the state the receipt records last (ADR 0022 §4's machine: accepted → running → exactly one of done, failed, canceled, capped — pending children sit at running while their boundary is open), the child session, and on settlement the child's stop text — its answer, when the state is done.
type WrapOption ¶
type WrapOption interface {
// contains filtered or unexported methods
}
A WrapOption configures one wrapped delegation tool.
func Async ¶
func Async() WrapOption
Async makes the wrapped tool's result an acceptance receipt instead of the child's answer (ADR 0022 D1): the middleware submits the child and returns at once — the receipt line is the tool result, model-visible bytes pinned by a golden — and the answer is delivered by the application in a later turn. Without it the delegation is sync: the call waits for the child session's turn and the result is the child's answer, as an ordinary subagent's is.
func ToolOptions ¶
func ToolOptions(opts ...weft.ToolOption) WrapOption
ToolOptions forwards weft tool options to the underlying subagent tool — Timeout, MaxResultBytes, a snippet — which otherwise the wrap would hide. RequireApproval composes as on any tool: it gates the act of delegating (ADR 0014).