Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e21cee7
Keep each section's raw Markdown in the parser
vinniefalco Oct 10, 2026
a567e41
Split find_visible out of resolve_sibling
vinniefalco Oct 10, 2026
d24da1d
Replace list_from_section with prompt.section/list
vinniefalco Oct 10, 2026
601fcb2
Close plan: prompt-namespace-reads
vinniefalco Oct 10, 2026
532497a
Split build_sections into named check helpers
vinniefalco Oct 10, 2026
c47e802
Give H1's visible set one helper, rename resolve_sibling
vinniefalco Oct 10, 2026
53f2aed
Mark every finished record's open tasks completed
vinniefalco Oct 10, 2026
1178c76
Rename fanout-arm test and fix reserved-alias docs
vinniefalco Oct 10, 2026
ceafc3a
Close plan: prompt-reads-debt-removal
vinniefalco Oct 10, 2026
05d130e
Tidy up vibe/ plans
vinniefalco Oct 11, 2026
c41bfb8
Add a one-line description to each Plugin package
vinniefalco Oct 11, 2026
e2a5ad4
Install Plugins by name from a package registry
vinniefalco Oct 11, 2026
6ee1885
Close plan: package-registry
vinniefalco Oct 11, 2026
7fb0769
Correct the native subagents and Plugin package docs
vinniefalco Oct 11, 2026
070b972
Make the UnknownPackage advice followable
vinniefalco Oct 11, 2026
363cca1
Resolve jump, call, and fanout targets in place
vinniefalco Oct 11, 2026
5a617ea
Read prompt sections in place at section entry
vinniefalco Oct 11, 2026
9ac7ee8
Borrow parser intermediates from the prompt body
vinniefalco Oct 11, 2026
cf4a94b
Store each prompt's body once and read its text in place
vinniefalco Oct 11, 2026
e62bd6d
Close plan: native-docs-debt-removal
vinniefalco Oct 11, 2026
1ce5831
Run one round's catalog tool calls in parallel
vinniefalco Oct 11, 2026
a490542
Move fanout into the tasks table as tasks.fanout
vinniefalco Oct 11, 2026
83d40b1
Close plan: parallel-tool-calls
vinniefalco Oct 11, 2026
3121b18
Keep Workshop waits answerable when calls run in parallel
vinniefalco Oct 11, 2026
fa59d5f
Close plan: workshop-waits
vinniefalco Oct 11, 2026
496d15a
Decide a tool call's route once and type its call site
vinniefalco Oct 11, 2026
2da0fe8
Document sibling calls and split the tool_call parse tests
vinniefalco Oct 11, 2026
bc49d96
Close plan: easy-fixes
vinniefalco Oct 11, 2026
1f7f88d
Report the tool calls each step withdraws
vinniefalco Oct 12, 2026
1b8184f
Run at most the cap of tool calls, oldest first
vinniefalco Oct 12, 2026
9cdef93
Close plan: tool-call-cap
vinniefalco Oct 12, 2026
0ab6277
Document every occasion that drops a Plugin call
vinniefalco Oct 12, 2026
1fff0b7
Pin that cancelling a task drops its operator question
vinniefalco Oct 12, 2026
0442510
Close plan: tool-call-cap-debt
vinniefalco Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Four words have exactly one meaning each, everywhere in this repository: code co
- **Engine**: the `promptforge` and `promptforge-*` crates. The Engine parses a prompt and steps a run. Whenever a run needs a model reply, a tool result, a timer, or a file, the Engine emits an effect and waits for its caller to answer it. In production the caller is the Harness.
- **Harness**: the `harness` and `harness-*` crates. The Harness steps the Engine, performs every effect, returns each answer, and records every run through the recorder the Host supplies. Production code runs prompts only through the Harness.
- **Host**: an application that runs prompts through the Harness, such as Workshop or Papergate. The Host makes every policy decision. It runs prompts only through the Harness; it may also use the Engine's parser and types to read prompts and show events.
- **Plugin**: a `plugin-*` crate implementing `promptforge-plugin`, installed into a `HostContext` under a local name the Host chooses, such as `web`. Every tool it offers sits under that name (`web/fetch`). Every run receives every usable Plugin's tools: those of each installed Plugin that built and whose per-run services the run has. Declaring a Plugin under `plugins:` in a prompt's frontmatter installs its Lua prelude and makes it required, and the prompt's Lua decides what the model sees. A Plugin has no other name: never capability, pack, or addon.
- **Plugin**: a package of tools a Host adds to its package registry and installs into a `HostContext` by package name, such as `promptforge/web`, under a local name the Host chooses, such as `web`. Every tool it offers sits under that name (`web/fetch`). A Plugin is linked (a `plugin-*` crate implementing `promptforge-plugin`), library (loaded from a dynamic library; not built yet), or Engine-native (`promptforge/subagents`, reserved for subagent tools the Engine will implement under the fixed name `subagents`, which no other Plugin may take; not built yet). Linked and library Plugins use only the `promptforge-plugin` API. Every run receives every usable Plugin's tools: those of each installed Plugin that built and whose per-run services the run has. Declaring a Plugin under `plugins:` in a prompt's frontmatter installs its Lua prelude and makes it required, and the prompt's Lua decides what the model sees. A Plugin has no other name: never capability, pack, or addon.

### Using the terms

Expand Down
184 changes: 149 additions & 35 deletions crates/harness-internal/runner/src/effect_loop-flight.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
//! The effects in flight: each chat, tool-call, and timer effect's
//! performer future, polled inside the run's own future beside the rest.
//! performer future, polled inside the run's own future beside the rest,
//! and the tool calls waiting for a slot.
//!
//! Every future sits in one `FuturesUnordered` as an `Abortable` future
//! under `catch_unwind`, keyed by its effect id, the provenance the effect
//! was issued under, and whether it is a tool call that survives stops. A
//! future that lands yields its effect's answer, and one that panics
//! yields `Dropped` with the panic logged. An aborted future leaves the
//! keyed set at once, since the loop answers its effect as it aborts it,
//! and is torn down the next time the set is polled; whatever it yields
//! then is discarded, so the run never sees two answers for one effect.
//! Every future in flight sits in one `FuturesUnordered` as an `Abortable`
//! future under `catch_unwind`, keyed by its effect id, the provenance the
//! effect was issued under, whether it is a tool call, and whether it
//! survives stops. A future that lands yields its effect's answer, and one
//! that panics yields `Dropped` with the panic logged. An aborted future
//! leaves the keyed set at once, since the loop answers its effect as it
//! aborts it, and is torn down the next time the set is polled; whatever
//! it yields then is discarded, so the run never sees two answers for one
//! effect.
//!
//! At most `cap` tool calls are in flight at once; chat and timer effects
//! never wait and never count toward it. A tool call beyond the cap waits
//! as its performer future, not yet polled, so a waiting call has started
//! nothing. Whenever a slot frees, because a call landed, panicked, or was
//! aborted, the waiting call with the lowest effect id starts. Ids follow
//! issue order across the run, so the oldest batch finishes first, and a
//! new call waits behind every call already waiting, so none starves.
//!
//! An abort takes waiting calls by the same reach as calls in flight: a
//! cancel takes every effect, a stop every effect but the tool calls that
//! survive stops, and a withdrawal the tool calls a step withdrew. A
//! waiting call it takes is dropped unstarted, and the loop answers it
//! `Dropped` like the rest. Every effect waiting or in flight is owed one
//! answer.

use std::any::Any;
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::num::NonZeroUsize;
use std::panic::AssertUnwindSafe;
use std::task::{Context, Poll, Waker};

Expand All @@ -27,13 +45,28 @@ use crate::performers::BoxFuture;
/// the abort the loop applied.
type Landing = (EffectId, Result<std::thread::Result<EffectAnswer>, Aborted>);

/// Which effects in flight an abort reaches.
/// Which held effects an abort reaches.
#[derive(Clone, Copy, Debug)]
pub(super) enum Reach {
/// Every effect in flight: a cancel.
pub(super) enum Reach<'a> {
/// Every effect, waiting or in flight: a cancel.
All,
/// Every effect but the tool calls that survive stops: a stop.
AllButSurvivors,
/// The listed effects, waiting or in flight: the tool calls a step
/// withdrew, in effect order.
Withdrawn(&'a [EffectId]),
}

impl Reach<'_> {
/// Whether this reach takes effect `id`, which survives stops when
/// `survives_stop` is set.
fn takes(self, id: EffectId, survives_stop: bool) -> bool {
match self {
Reach::All => true,
Reach::AllButSurvivors => !survives_stop,
Reach::Withdrawn(ids) => ids.binary_search(&id).is_ok(),
}
}
}

/// One effect in flight: what the loop needs to answer or drop it.
Expand All @@ -42,45 +75,114 @@ struct InFlight {
provenance: Provenance,
/// Aborts the effect's future.
abort: AbortHandle,
/// Whether the effect is a tool call, which counts toward the cap.
tool_call: bool,
/// Whether the effect is a tool call that survives stops, which a
/// stop leaves alone.
survives_stop: bool,
}

/// The effects in flight and their futures.
/// A tool call waiting for a slot: its performer future, not yet polled.
struct Waiting {
provenance: Provenance,
survives_stop: bool,
answer: BoxFuture<EffectAnswer>,
}

/// The effects in flight and their futures, and the tool calls waiting
/// for a slot.
pub(super) struct Flights {
futures: FuturesUnordered<BoxFuture<Landing>>,
/// The effects still owed an answer. A landing for an id not here is
/// an aborted future's late end and is discarded.
/// The effects in flight still owed an answer. A landing for an id not
/// here is an aborted future's late end and is discarded.
effects: HashMap<EffectId, InFlight>,
/// Tool calls waiting for a slot; the lowest id starts first.
waiting: BTreeMap<EffectId, Waiting>,
/// The most tool calls in flight at once.
cap: NonZeroUsize,
}

impl Flights {
pub(super) fn new() -> Self {
pub(super) fn new(cap: NonZeroUsize) -> Self {
Self {
futures: FuturesUnordered::new(),
effects: HashMap::new(),
waiting: BTreeMap::new(),
cap,
}
}

/// Whether no effect is owed an answer.
pub(super) fn is_empty(&self) -> bool {
self.effects.is_empty()
self.effects.is_empty() && self.waiting.is_empty()
}

/// How many effects are owed an answer.
/// How many effects are owed an answer, waiting calls included.
pub(super) fn len(&self) -> usize {
self.effects.len()
self.effects.len() + self.waiting.len()
}

/// Puts `answer`, the performer future of chat or timer effect `id`,
/// in flight under `provenance`. It never waits and never counts
/// toward the cap.
pub(super) fn start(
&mut self,
id: EffectId,
provenance: Provenance,
answer: BoxFuture<EffectAnswer>,
) {
self.launch(id, provenance, false, false, answer);
}

/// Queues `answer`, the performer future of tool call `id`, under
/// `provenance`, then starts waiting calls, lowest id first, while
/// fewer than `cap` tool calls are in flight. `survives_stop` marks a
/// call that survives stops.
pub(super) fn queue(
&mut self,
id: EffectId,
provenance: Provenance,
survives_stop: bool,
answer: BoxFuture<EffectAnswer>,
) {
self.waiting.insert(
id,
Waiting {
provenance,
survives_stop,
answer,
},
);
self.fill();
}

/// Starts waiting calls, lowest id first, while fewer than `cap` tool
/// calls are in flight.
fn fill(&mut self) {
let mut running = self
.effects
.values()
.filter(|in_flight| in_flight.tool_call)
.count();
while running < self.cap.get() {
let Some((id, call)) = self.waiting.pop_first() else {
return;
};
self.launch(id, call.provenance, true, call.survives_stop, call.answer);
running += 1;
}
}

/// Puts `answer`, the performer future of effect `id`, in flight under
/// `provenance`, inside a span that records the effect id, the task
/// path, and the task-local sequence, so a run's effects trace as a
/// group. `survives_stop` marks a tool call that survives stops.
pub(super) fn start(
/// group. `tool_call` marks a tool call, and `survives_stop` one that
/// survives stops.
fn launch(
&mut self,
id: EffectId,
provenance: Provenance,
tool_call: bool,
survives_stop: bool,
answer: BoxFuture<EffectAnswer>,
) {
Expand All @@ -99,14 +201,16 @@ impl Flights {
InFlight {
provenance,
abort,
tool_call,
survives_stop,
},
);
}

/// Polls for the next effect whose future landed, with its provenance
/// and answer. A future that panicked lands as `Dropped`, and the
/// panic is logged against its effect.
/// panic is logged against its effect. A tool call that lands frees
/// its slot for the next waiting call.
pub(super) fn poll_landed(
&mut self,
cx: &mut Context<'_>,
Expand All @@ -118,6 +222,9 @@ impl Flights {
let Some(in_flight) = self.effects.remove(&id) else {
continue;
};
if in_flight.tool_call {
self.fill();
}
let answer = match landed {
Ok(Ok(answer)) => answer,
Ok(Err(panic)) => {
Expand Down Expand Up @@ -146,27 +253,34 @@ impl Flights {
}
}

/// Aborts every effect in flight that `reach` takes and returns each
/// one's id and provenance in effect order, for the loop to answer
/// `Dropped`.
pub(super) fn abort(&mut self, reach: Reach) -> Vec<(EffectId, Provenance)> {
let mut chosen: Vec<EffectId> = self
/// Takes every effect waiting or in flight that `reach` takes:
/// drops each waiting call's future unstarted, aborts each effect in
/// flight, and returns each one's id and provenance in effect order,
/// for the loop to answer `Dropped`. Then starts waiting calls in the
/// slots it freed, so it never starts a call the same reach takes.
pub(super) fn abort(&mut self, reach: Reach<'_>) -> Vec<(EffectId, Provenance)> {
let in_flight = self
.effects
.iter()
.filter(|(_, in_flight)| match reach {
Reach::All => true,
Reach::AllButSurvivors => !in_flight.survives_stop,
})
.map(|(id, _)| *id)
.collect();
chosen.sort_by_key(|id| id.get());
.filter(|(id, in_flight)| reach.takes(**id, in_flight.survives_stop))
.map(|(id, _)| *id);
let waiting = self
.waiting
.iter()
.filter(|(id, call)| reach.takes(**id, call.survives_stop))
.map(|(id, _)| *id);
let mut chosen: Vec<EffectId> = in_flight.chain(waiting).collect();
chosen.sort_unstable();
let mut aborted = Vec::with_capacity(chosen.len());
for id in chosen {
if let Some(in_flight) = self.effects.remove(&id) {
in_flight.abort.abort();
aborted.push((id, in_flight.provenance));
} else if let Some(call) = self.waiting.remove(&id) {
aborted.push((id, call.provenance));
}
}
self.fill();
aborted
}
}
Expand Down
Loading
Loading