10 Commits
111 changed files with 10393 additions and 1261 deletions
Generated
+40
View File
@@ -1016,6 +1016,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.10.7"
@@ -1243,6 +1249,18 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "flow"
version = "0.1.0"
dependencies = [
"decodal",
"pretty_assertions",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror 2.0.18",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -3001,6 +3019,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -3028,6 +3056,7 @@ version = "0.1.0"
name = "protocol"
version = "0.1.0"
dependencies = [
"schemars",
"serde",
"serde_json",
"tokio",
@@ -3866,6 +3895,7 @@ name = "session-store"
version = "0.1.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"futures",
"llm-engine",
"protocol",
@@ -5962,6 +5992,7 @@ dependencies = [
"clap",
"client",
"dotenv",
"flow",
"fs4",
"futures",
"futures-util",
@@ -6005,6 +6036,7 @@ dependencies = [
"axum",
"base64 0.22.1",
"decodal",
"flow",
"futures",
"llm-engine",
"manifest",
@@ -6022,6 +6054,7 @@ dependencies = [
"tokio-tungstenite 0.29.0",
"toml",
"tower",
"uuid",
"workdir",
"worker",
]
@@ -6070,6 +6103,12 @@ dependencies = [
"markup5ever",
]
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yoi"
version = "0.1.0"
@@ -6121,6 +6160,7 @@ dependencies = [
"async-trait",
"axum",
"chrono",
"flow",
"futures",
"manifest",
"memory",
+2
View File
@@ -18,6 +18,7 @@ members = [
"crates/lint-common",
"crates/tools",
"crates/fs-operation",
"crates/flow",
"crates/workdir",
"crates/tui",
"crates/memory",
@@ -44,6 +45,7 @@ default-members = [
"crates/lint-common",
"crates/tools",
"crates/fs-operation",
"crates/flow",
"crates/workdir",
"crates/tui",
"crates/memory",
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "flow"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
decodal.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
[dev-dependencies]
pretty_assertions = "1"
+70
View File
@@ -0,0 +1,70 @@
use crate::{CompiledFlowDefinition, FlowCompileError, compile_flow_source};
pub const CODER_REVIEW_FLOW_SLUG: &str = "coder-review";
const CODER_REVIEW_FLOW_SOURCE: &str = include_str!("../../../resources/flows/coder-review.dcdl");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuiltinFlowSource {
pub slug: &'static str,
/// Monotonic resource revision. Increment when built-in semantics change;
/// Runtime also pins the compiled content digest.
pub revision: u64,
pub path: &'static str,
pub content: &'static str,
}
impl BuiltinFlowSource {
pub fn compile(self) -> Result<CompiledFlowDefinition, FlowCompileError> {
compile_flow_source(self.content)
}
}
pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
match slug {
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG,
revision: 1,
path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE,
}),
_ => None,
}
}
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG,
revision: 1,
path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE,
}];
SOURCES
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
assert!(!builtin_flow_sources().is_empty());
for source in builtin_flow_sources() {
assert!(
source.revision > 0,
"built-in Flow revision must be positive"
);
let definition = source.compile().unwrap_or_else(|error| {
panic!(
"builtin Flow {} failed to compile: {:?}",
source.slug, error.diagnostics
)
});
assert_eq!(definition.name, source.slug);
assert_eq!(
builtin_flow_source(source.slug).map(|item| item.content),
Some(source.content)
);
}
}
}
File diff suppressed because it is too large Load Diff
+709
View File
@@ -0,0 +1,709 @@
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fmt;
use std::fmt::Write as _;
use decodal::{Engine, LoadedSource, SourceLoader};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const FLOW_SCHEMA_VERSION: u32 = 1;
pub const CANCELLED_STATE_ID: &str = "$cancelled";
pub const CANCEL_TRANSITION_ID: &str = "$cancel";
pub const CANCEL_CONDITION: &str = "An exceptional condition makes it impossible to continue the current instructions and reach a normal terminal state with the available tools, scope, and session context.";
const MAX_SOURCE_BYTES: usize = 256 * 1024;
const MAX_STATES: usize = 128;
const MAX_TRANSITIONS_PER_STATE: usize = 32;
const MAX_TEXT_BYTES: usize = 32 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct StateId(String);
impl StateId {
pub fn new(value: impl Into<String>) -> Result<Self, String> {
let value = value.into();
validate_identifier("state", &value)?;
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for StateId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TransitionId(String);
impl TransitionId {
pub fn new(value: impl Into<String>) -> Result<Self, String> {
let value = value.into();
validate_identifier("transition", &value)?;
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for TransitionId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompiledFlowDefinition {
pub schema_version: u32,
pub name: String,
pub initial: StateId,
pub states: BTreeMap<StateId, CompiledState>,
pub content_digest: String,
}
impl CompiledFlowDefinition {
pub fn state(&self, state_id: &StateId) -> Option<&CompiledState> {
self.states.get(state_id)
}
pub fn outgoing(&self, state_id: &StateId) -> Option<&[CompiledTransition]> {
self.state(state_id)
.map(|state| state.transitions.as_slice())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompiledState {
pub id: StateId,
pub instructions: String,
pub terminal: bool,
pub transitions: Vec<CompiledTransition>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompiledTransition {
pub id: TransitionId,
pub target: StateId,
pub condition: String,
pub synthetic: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FlowDiagnostic {
pub code: String,
pub path: String,
pub message: String,
}
impl FlowDiagnostic {
fn new(code: impl Into<String>, path: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
path: path.into(),
message: message.into(),
}
}
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
#[error("Flow definition is invalid")]
pub struct FlowCompileError {
pub diagnostics: Vec<FlowDiagnostic>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct FlowSource {
schema_version: u32,
name: String,
initial: String,
states: BTreeMap<String, StateSource>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct StateSource {
instructions: String,
#[serde(default)]
terminal: bool,
#[serde(default)]
transitions: BTreeMap<String, TransitionSource>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct TransitionSource {
target: String,
condition: String,
}
pub fn compile_flow_source(content: &str) -> Result<CompiledFlowDefinition, FlowCompileError> {
if content.len() > MAX_SOURCE_BYTES {
return Err(one_diagnostic(
"source_too_large",
"$",
format!("Flow source exceeds {MAX_SOURCE_BYTES} bytes"),
));
}
let mut engine = Engine::new(RejectImports);
let module = engine
.add_root_source("flow.dcdl", "flow.dcdl", content)
.map_err(|error| {
one_diagnostic("dcdl_parse", "$", format!("DCDL parsing failed: {error:?}"))
})?;
let value = engine.eval_module(module).map_err(|error| {
one_diagnostic(
"dcdl_evaluation",
"$",
format!("DCDL evaluation failed: {error:?}"),
)
})?;
let data = engine.materialize(&value).map_err(|error| {
one_diagnostic(
"dcdl_materialization",
"$",
format!("DCDL materialization failed: {error:?}"),
)
})?;
let value = decodal_data_to_json(&data);
let source = serde_json::from_value::<FlowSource>(value).map_err(|error| {
one_diagnostic(
"schema_decode",
"$",
format!("Flow source does not match schema: {error}"),
)
})?;
compile_typed_source(source, content)
}
fn compile_typed_source(
source: FlowSource,
content: &str,
) -> Result<CompiledFlowDefinition, FlowCompileError> {
let mut diagnostics = Vec::new();
if source.schema_version != FLOW_SCHEMA_VERSION {
diagnostics.push(FlowDiagnostic::new(
"unsupported_schema_version",
"schema_version",
format!(
"unsupported Flow schema version {}; expected {FLOW_SCHEMA_VERSION}",
source.schema_version
),
));
}
if let Err(message) = validate_identifier("Flow", &source.name) {
diagnostics.push(FlowDiagnostic::new("invalid_name", "name", message));
}
if source.states.is_empty() {
diagnostics.push(FlowDiagnostic::new(
"states_empty",
"states",
"Flow must declare at least one state",
));
}
if source.states.len() > MAX_STATES {
diagnostics.push(FlowDiagnostic::new(
"too_many_states",
"states",
format!("Flow declares more than {MAX_STATES} states"),
));
}
if source.initial == CANCELLED_STATE_ID {
diagnostics.push(FlowDiagnostic::new(
"reserved_state",
"initial",
format!("{CANCELLED_STATE_ID} is reserved by the Flow runtime"),
));
}
let initial = StateId::new(source.initial.clone()).unwrap_or_else(|message| {
diagnostics.push(FlowDiagnostic::new("invalid_initial", "initial", message));
StateId(source.initial.clone())
});
let declared_names = source.states.keys().cloned().collect::<BTreeSet<_>>();
if !declared_names.contains(initial.as_str()) {
diagnostics.push(FlowDiagnostic::new(
"initial_not_found",
"initial",
format!("initial state {initial:?} is not declared"),
));
}
if declared_names.contains(CANCELLED_STATE_ID) {
diagnostics.push(FlowDiagnostic::new(
"reserved_state",
format!("states.{CANCELLED_STATE_ID}"),
format!("{CANCELLED_STATE_ID} is reserved by the Flow runtime"),
));
}
let mut states = BTreeMap::new();
for (state_name, state_source) in source.states {
let state_path = format!("states.{state_name}");
let state_id = StateId::new(state_name.clone()).unwrap_or_else(|message| {
diagnostics.push(FlowDiagnostic::new(
"invalid_state_id",
state_path.clone(),
message,
));
StateId(state_name.clone())
});
validate_text(
&mut diagnostics,
"instructions",
&format!("{state_path}.instructions"),
&state_source.instructions,
state_source.terminal,
);
if state_source.terminal && !state_source.transitions.is_empty() {
diagnostics.push(FlowDiagnostic::new(
"terminal_has_transitions",
format!("{state_path}.transitions"),
"terminal states must not declare outgoing transitions",
));
}
if !state_source.terminal && state_source.transitions.is_empty() {
diagnostics.push(FlowDiagnostic::new(
"non_terminal_without_transition",
format!("{state_path}.transitions"),
"non-terminal states must declare at least one transition",
));
}
if state_source.transitions.len() > MAX_TRANSITIONS_PER_STATE {
diagnostics.push(FlowDiagnostic::new(
"too_many_transitions",
format!("{state_path}.transitions"),
format!("state declares more than {MAX_TRANSITIONS_PER_STATE} transitions"),
));
}
let mut transitions = Vec::new();
for (transition_name, transition_source) in state_source.transitions {
let transition_path = format!("{state_path}.transitions.{transition_name}");
if transition_name == CANCEL_TRANSITION_ID || transition_name.starts_with('$') {
diagnostics.push(FlowDiagnostic::new(
"reserved_transition",
transition_path.clone(),
"transition identifiers beginning with '$' are reserved by the Flow runtime",
));
}
let transition_id =
TransitionId::new(transition_name.clone()).unwrap_or_else(|message| {
diagnostics.push(FlowDiagnostic::new(
"invalid_transition_id",
transition_path.clone(),
message,
));
TransitionId(transition_name.clone())
});
if transition_source.target == CANCELLED_STATE_ID
|| transition_source.target.starts_with('$')
{
diagnostics.push(FlowDiagnostic::new(
"reserved_transition_target",
format!("{transition_path}.target"),
"definition authors cannot target runtime-reserved states",
));
}
let target = StateId::new(transition_source.target.clone()).unwrap_or_else(|message| {
diagnostics.push(FlowDiagnostic::new(
"invalid_transition_target",
format!("{transition_path}.target"),
message,
));
StateId(transition_source.target.clone())
});
if !declared_names.contains(target.as_str()) {
diagnostics.push(FlowDiagnostic::new(
"transition_target_not_found",
format!("{transition_path}.target"),
format!("transition target {target:?} is not declared"),
));
}
validate_text(
&mut diagnostics,
"condition",
&format!("{transition_path}.condition"),
&transition_source.condition,
false,
);
transitions.push(CompiledTransition {
id: transition_id,
target,
condition: transition_source.condition,
synthetic: false,
});
}
if !state_source.terminal {
transitions.push(CompiledTransition {
id: TransitionId(CANCEL_TRANSITION_ID.to_string()),
target: StateId(CANCELLED_STATE_ID.to_string()),
condition: CANCEL_CONDITION.to_string(),
synthetic: true,
});
}
states.insert(
state_id.clone(),
CompiledState {
id: state_id,
instructions: state_source.instructions,
terminal: state_source.terminal,
transitions,
},
);
}
if diagnostics.is_empty() {
validate_graph(&states, &initial, &mut diagnostics);
}
if !diagnostics.is_empty() {
return Err(FlowCompileError { diagnostics });
}
states.insert(
StateId(CANCELLED_STATE_ID.to_string()),
CompiledState {
id: StateId(CANCELLED_STATE_ID.to_string()),
instructions: String::new(),
terminal: true,
transitions: Vec::new(),
},
);
Ok(CompiledFlowDefinition {
schema_version: source.schema_version,
name: source.name,
initial,
states,
content_digest: content_digest(content),
})
}
fn validate_identifier(kind: &str, value: &str) -> Result<(), String> {
if value.is_empty() {
return Err(format!("{kind} identifier must not be empty"));
}
if value.len() > 128 {
return Err(format!("{kind} identifier exceeds 128 bytes"));
}
if value.starts_with('$') {
return Err(format!("{kind} identifier beginning with '$' is reserved"));
}
if !value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
{
return Err(format!(
"{kind} identifier must contain only ASCII letters, digits, '-' or '_'"
));
}
Ok(())
}
fn validate_text(
diagnostics: &mut Vec<FlowDiagnostic>,
field: &str,
path: &str,
value: &str,
allow_empty: bool,
) {
if !allow_empty && value.trim().is_empty() {
diagnostics.push(FlowDiagnostic::new(
format!("{field}_empty"),
path,
format!("{field} must not be empty"),
));
}
if value.len() > MAX_TEXT_BYTES {
diagnostics.push(FlowDiagnostic::new(
format!("{field}_too_large"),
path,
format!("{field} exceeds {MAX_TEXT_BYTES} bytes"),
));
}
}
fn validate_graph(
states: &BTreeMap<StateId, CompiledState>,
initial: &StateId,
diagnostics: &mut Vec<FlowDiagnostic>,
) {
if !states.contains_key(initial) {
return;
}
let mut reachable = BTreeSet::new();
let mut pending = VecDeque::from([initial.clone()]);
while let Some(current) = pending.pop_front() {
if !reachable.insert(current.clone()) {
continue;
}
if let Some(state) = states.get(&current) {
for transition in state
.transitions
.iter()
.filter(|transition| !transition.synthetic)
{
pending.push_back(transition.target.clone());
}
}
}
for state_id in states.keys() {
if !reachable.contains(state_id) {
diagnostics.push(FlowDiagnostic::new(
"unreachable_state",
format!("states.{state_id}"),
format!("state {state_id:?} is unreachable from initial state {initial:?}"),
));
}
}
let terminal_states = states
.values()
.filter(|state| state.terminal)
.map(|state| state.id.clone())
.collect::<BTreeSet<_>>();
if terminal_states.is_empty() {
diagnostics.push(FlowDiagnostic::new(
"terminal_missing",
"states",
"Flow must declare at least one terminal state",
));
return;
}
let mut reverse: BTreeMap<StateId, Vec<StateId>> = BTreeMap::new();
for state in states.values() {
for transition in state
.transitions
.iter()
.filter(|transition| !transition.synthetic)
{
reverse
.entry(transition.target.clone())
.or_default()
.push(state.id.clone());
}
}
let mut can_reach_terminal = terminal_states.clone();
let mut pending = terminal_states.into_iter().collect::<VecDeque<_>>();
while let Some(current) = pending.pop_front() {
for predecessor in reverse.get(&current).into_iter().flatten() {
if can_reach_terminal.insert(predecessor.clone()) {
pending.push_back(predecessor.clone());
}
}
}
for state_id in reachable {
if !can_reach_terminal.contains(&state_id) {
diagnostics.push(FlowDiagnostic::new(
"terminal_unreachable",
format!("states.{state_id}"),
format!(
"state {state_id:?} cannot reach a user-declared terminal state; the graph contains a closed non-terminal path"
),
));
}
}
}
fn content_digest(content: &str) -> String {
let digest = Sha256::digest(content.as_bytes());
let mut encoded = String::with_capacity(7 + digest.len() * 2);
encoded.push_str("sha256:");
for byte in digest {
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
}
encoded
}
struct RejectImports;
impl SourceLoader for RejectImports {
fn load(
&mut self,
_current_key: Option<&str>,
specifier: &str,
) -> decodal::Result<LoadedSource> {
Err(decodal::Diagnostic::new(
decodal::DiagnosticKind::Import,
decodal::Span::default(),
format!("Flow source imports are not enabled: {specifier}"),
))
}
}
fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value {
match data {
decodal::Data::Bool(value) => serde_json::Value::Bool(*value),
decodal::Data::Int(value) => serde_json::Value::Number(serde_json::Number::from(*value)),
decodal::Data::Float(value) => serde_json::Number::from_f64(*value)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
decodal::Data::String(value) => serde_json::Value::String(value.clone()),
decodal::Data::Array(values) => {
serde_json::Value::Array(values.iter().map(decodal_data_to_json).collect())
}
decodal::Data::Object(fields) => serde_json::Value::Object(
fields
.iter()
.map(|field| (field.name.clone(), decodal_data_to_json(&field.value)))
.collect(),
),
}
}
fn one_diagnostic(
code: impl Into<String>,
path: impl Into<String>,
message: impl Into<String>,
) -> FlowCompileError {
FlowCompileError {
diagnostics: vec![FlowDiagnostic::new(code, path, message)],
}
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_source() -> &'static str {
r#"{
schema_version = 1;
name = "coder-review";
initial = "code";
states = {
code = {
instructions = "Implement and validate the requested change.";
transitions = {
review = {
target = "review";
condition = "Implementation and validation evidence are present.";
};
};
};
review = {
instructions = "Read the independent review result.";
transitions = {
done = {
target = "done";
condition = "The independent reviewer approved the implementation.";
};
fix = {
target = "fix";
condition = "The independent reviewer requested concrete changes.";
};
};
};
fix = {
instructions = "Resolve every open review finding and validate the fixes.";
transitions = {
review = {
target = "review";
condition = "The requested changes are resolved and ready for re-review.";
};
};
};
done = {
instructions = "";
terminal = true;
};
};
}"#
}
#[test]
fn compiles_definition_and_injects_cancel_transition() {
let definition = compile_flow_source(valid_source()).expect("valid Flow");
assert_eq!(definition.name, "coder-review");
assert!(definition.content_digest.starts_with("sha256:"));
let code = definition
.state(&StateId::new("code").unwrap())
.expect("code state");
assert_eq!(code.transitions.len(), 2);
assert!(
code.transitions
.iter()
.any(|transition| transition.id.as_str() == CANCEL_TRANSITION_ID
&& transition.synthetic)
);
let cancelled = definition
.state(&StateId(CANCELLED_STATE_ID.to_string()))
.expect("synthetic cancelled state");
assert!(cancelled.terminal);
assert!(cancelled.instructions.is_empty());
}
#[test]
fn rejects_unknown_structural_fields() {
let source = valid_source().replace(
"schema_version = 1;",
"schema_version = 1; unexpected = true;",
);
let error = compile_flow_source(&source).unwrap_err();
assert_eq!(error.diagnostics[0].code, "schema_decode");
assert!(error.diagnostics[0].message.contains("unexpected"));
}
#[test]
fn rejects_definition_authored_cancel_target() {
let source = valid_source().replace("target = \"review\";", "target = \"$cancelled\";");
let error = compile_flow_source(&source).unwrap_err();
assert!(
error
.diagnostics
.iter()
.any(|diagnostic| { diagnostic.code == "reserved_transition_target" })
);
}
#[test]
fn rejects_unreachable_state() {
let source = valid_source().replace(
"done = {\n instructions = \"\";",
"unused = { instructions = \"unused\"; terminal = true; };\n done = {\n instructions = \"\";",
);
let error = compile_flow_source(&source).unwrap_err();
assert!(
error
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "unreachable_state")
);
}
#[test]
fn rejects_closed_non_terminal_cycle_even_with_synthetic_cancel() {
let source = r#"{
schema_version = 1;
name = "closed-cycle";
initial = "a";
states = {
a = {
instructions = "a";
transitions = { to_b = { target = "b"; condition = "go b"; }; };
};
b = {
instructions = "b";
transitions = { to_a = { target = "a"; condition = "go a"; }; };
};
done = { instructions = ""; terminal = true; };
};
}"#;
let error = compile_flow_source(source).unwrap_err();
assert!(
error
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "terminal_unreachable")
);
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Declarative Flow definitions and deterministic transition coordination.
//!
//! DCDL is a source format only. The Flow domain owns the typed source
//! schema, semantic validation, immutable transition snapshots, and state
//! transition rules.
mod builtin;
mod coordinator;
mod definition;
mod selector;
pub use builtin::*;
pub use coordinator::*;
pub use definition::*;
pub use selector::*;
+199
View File
@@ -0,0 +1,199 @@
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FlowSelector {
Builtin { slug: String },
Workspace { slug: String },
}
impl FlowSelector {
pub fn builtin(slug: impl Into<String>) -> Result<Self, FlowSelectorError> {
let slug = slug.into();
validate_slug(&slug)?;
Ok(Self::Builtin { slug })
}
pub fn workspace(slug: impl Into<String>) -> Result<Self, FlowSelectorError> {
let slug = slug.into();
validate_slug(&slug)?;
Ok(Self::Workspace { slug })
}
pub fn slug(&self) -> &str {
match self {
Self::Builtin { slug } | Self::Workspace { slug } => slug,
}
}
pub fn source_kind(&self) -> FlowSourceKind {
match self {
Self::Builtin { .. } => FlowSourceKind::Builtin,
Self::Workspace { .. } => FlowSourceKind::Workspace,
}
}
}
impl fmt::Display for FlowSelector {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Builtin { slug } => write!(formatter, "builtin:{slug}"),
Self::Workspace { slug } => write!(formatter, "workspace:{slug}"),
}
}
}
impl FromStr for FlowSelector {
type Err = FlowSelectorError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let (source, slug) = value.split_once(':').ok_or_else(|| {
FlowSelectorError::InvalidFormat(
"Flow selector must be source-qualified as builtin:<slug> or workspace:<slug>"
.to_string(),
)
})?;
if slug.contains(':') {
return Err(FlowSelectorError::InvalidFormat(
"Flow selector must contain exactly one ':' separator".to_string(),
));
}
match source {
"builtin" => Self::builtin(slug),
"workspace" => Self::workspace(slug),
other => Err(FlowSelectorError::UnknownSource(other.to_string())),
}
}
}
impl Serialize for FlowSelector {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for FlowSelector {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FlowSourceKind {
Builtin,
Workspace,
}
impl FlowSourceKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Builtin => "builtin",
Self::Workspace => "workspace",
}
}
}
impl fmt::Display for FlowSourceKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FlowSourceResolveRequest {
pub selector: FlowSelector,
}
/// Immutable source snapshot resolved by Workspace authority for one Runtime.
///
/// This is read-only source authority. Starting or mutating a Flow instance is
/// deliberately not part of this response; Runtime persists the snapshot in
/// the target Worker's durable state before execution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedFlowSource {
pub selector: FlowSelector,
pub workspace_id: String,
pub flow_id: String,
pub revision: u64,
pub content_digest: String,
pub definition: crate::CompiledFlowDefinition,
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum FlowSelectorError {
#[error("{0}")]
InvalidFormat(String),
#[error("unknown Flow selector source {0:?}")]
UnknownSource(String),
#[error("invalid Flow selector slug: {0}")]
InvalidSlug(String),
}
fn validate_slug(slug: &str) -> Result<(), FlowSelectorError> {
if slug.is_empty() {
return Err(FlowSelectorError::InvalidSlug(
"slug must not be empty".to_string(),
));
}
if slug.len() > 128 {
return Err(FlowSelectorError::InvalidSlug(
"slug exceeds 128 bytes".to_string(),
));
}
if !slug
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
{
return Err(FlowSelectorError::InvalidSlug(
"slug must contain only ASCII letters, digits, '-' or '_'".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selector_requires_explicit_known_source() {
assert_eq!(
"builtin:coder-review".parse::<FlowSelector>().unwrap(),
FlowSelector::Builtin {
slug: "coder-review".to_string()
}
);
assert_eq!(
"workspace:coder-review"
.parse::<FlowSelector>()
.unwrap()
.to_string(),
"workspace:coder-review"
);
assert!("coder-review".parse::<FlowSelector>().is_err());
assert!("project:coder-review".parse::<FlowSelector>().is_err());
assert!("builtin:bad/path".parse::<FlowSelector>().is_err());
assert!("builtin:a:b".parse::<FlowSelector>().is_err());
}
#[test]
fn selector_serde_is_one_canonical_string() {
let selector = FlowSelector::builtin("coder-review").unwrap();
let json = serde_json::to_string(&selector).unwrap();
assert_eq!(json, r#""builtin:coder-review""#);
assert_eq!(
serde_json::from_str::<FlowSelector>(&json).unwrap(),
selector
);
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ license.workspace = true
[features]
default = []
codex = ["dep:base64", "dep:chrono"]
codex = ["dep:chrono"]
[dependencies]
serde = { workspace = true, features = ["derive"] }
@@ -21,7 +21,7 @@ tokio-util = "0.7"
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "native-tls", "http2"] }
eventsource-stream = "0.2"
zstd = "0.13"
base64 = { version = "0.22.1", optional = true }
base64 = "0.22.1"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
llm-engine-macros = { workspace = true }
+32 -1
View File
@@ -1618,11 +1618,12 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
// Route per-result pushes through the callback path so
// observers see each tool result as it lands.
let items = results.into_iter().map(|result| {
Item::tool_result_item(
Item::tool_result_item_with_attachments(
&result.tool_use_id,
&result.summary,
result.content,
result.is_error,
result.attachments,
)
});
self.append_history_items(items)?;
@@ -2159,8 +2160,38 @@ fn item_kind(item: &Item) -> &'static str {
#[cfg(test)]
mod tests {
use super::*;
use crate::tool::{Attachment, ImageAttachment};
use std::time::Duration;
#[test]
fn tool_attachment_round_trips_through_durable_history_json() {
let body: Arc<[u8]> = Arc::from(&b"image-body"[..]);
let items = vec![Item::tool_result_item_with_attachments(
"call_image",
"attached",
None,
false,
vec![Attachment::Image(ImageAttachment::new(
"image/png",
body.clone(),
))],
)];
let persisted = serde_json::to_string(&items).unwrap();
assert!(persisted.contains("aW1hZ2UtYm9keQ=="));
let restored: Vec<Item> = serde_json::from_str(&persisted).unwrap();
assert_eq!(restored, items);
assert!(matches!(
&restored[0],
Item::ToolResult { attachments, .. }
if matches!(
attachments.as_slice(),
[Attachment::Image(image)]
if image.mime_type() == "image/png" && image.data() == body.as_ref()
)
));
}
#[tokio::test]
async fn first_stream_event_timeout_returns_retryable_timeout() {
let stream: ResponseStream = Box::pin(futures::stream::pending());
@@ -5,10 +5,13 @@
use serde::Serialize;
use serde_json::Value;
use crate::llm_client::{
Request,
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
types::{Item, Role, ToolDefinition, parse_tool_arguments},
use crate::{
llm_client::{
Request,
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
types::{ContentPart, Item, Role, ToolDefinition, image_data_url, parse_tool_arguments},
},
tool::Attachment,
};
use super::OpenAIScheme;
@@ -134,7 +137,7 @@ impl OpenAIScheme {
}
// Convert items to messages
messages.extend(self.convert_items_to_messages(&request.items));
messages.extend(self.convert_items_to_messages(&request.items, capability.vision));
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
@@ -185,12 +188,38 @@ impl OpenAIScheme {
/// - Assistant messages have role "assistant"
/// - Tool calls are within assistant messages as tool_calls array
/// - Tool results have role "tool" with tool_call_id
fn convert_items_to_messages(&self, items: &[Item]) -> Vec<OpenAIMessage> {
fn flush_pending_tool_result_images(
messages: &mut Vec<OpenAIMessage>,
pending_images: &mut Vec<OpenAIContentPart>,
) {
if !pending_images.is_empty() {
messages.push(OpenAIMessage {
role: "user".to_string(),
content: Some(OpenAIContent::Parts(std::mem::take(pending_images))),
tool_calls: vec![],
tool_call_id: None,
name: None,
});
}
}
fn convert_items_to_messages(
&self,
items: &[Item],
supports_images: bool,
) -> Vec<OpenAIMessage> {
let mut messages = Vec::new();
let mut pending_tool_calls: Vec<OpenAIToolCall> = Vec::new();
let mut pending_assistant_text: Option<String> = None;
let mut pending_tool_result_images: Vec<OpenAIContentPart> = Vec::new();
for item in items {
if !matches!(item, Item::ToolResult { .. }) {
Self::flush_pending_tool_result_images(
&mut messages,
&mut pending_tool_result_images,
);
}
match item {
Item::Message { role, content, .. } => {
// Flush pending tool calls
@@ -205,16 +234,17 @@ impl OpenAIScheme {
Role::Assistant => "assistant",
Role::System => "system",
};
let text_content: String = content
.iter()
.map(|p| p.as_text())
.collect::<Vec<_>>()
.join("");
let message_content = OpenAIContent::Text(
content
.iter()
.map(ContentPart::as_text)
.collect::<Vec<_>>()
.join(""),
);
messages.push(OpenAIMessage {
role: openai_role.to_string(),
content: Some(OpenAIContent::Text(text_content)),
content: Some(message_content),
tool_calls: vec![],
tool_call_id: None,
name: None,
@@ -244,19 +274,35 @@ impl OpenAIScheme {
call_id,
summary,
content,
attachments,
..
} => {
// Flush pending tool calls before tool result
// OpenAI requires every parallel tool result before a new user message.
self.flush_pending_assistant(
&mut messages,
&mut pending_tool_calls,
&mut pending_assistant_text,
);
let text = match content {
let mut text = match content {
Some(c) => format!("{summary}\n{c}"),
None => summary.clone(),
};
if supports_images {
pending_tool_result_images.extend(attachments.iter().map(|attachment| {
let Attachment::Image(image) = attachment;
OpenAIContentPart::ImageUrl {
image_url: ImageUrl {
url: image_data_url(image.mime_type(), image.data()),
},
}
}));
} else if !attachments.is_empty() {
text.push_str(&format!(
"\n[{} image attachment(s) omitted: model does not support images]",
attachments.len()
));
}
messages.push(OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text(text)),
@@ -284,6 +330,7 @@ impl OpenAIScheme {
&mut pending_tool_calls,
&mut pending_assistant_text,
);
Self::flush_pending_tool_result_images(&mut messages, &mut pending_tool_result_images);
messages
}
@@ -334,6 +381,13 @@ mod tests {
}
}
fn vision_cap() -> ModelCapability {
ModelCapability {
vision: true,
..cap()
}
}
#[test]
fn test_build_simple_request() {
let scheme = OpenAIScheme::new();
@@ -439,4 +493,94 @@ mod tests {
assert_eq!(body.messages[1].tool_calls.len(), 1);
assert_eq!(body.messages[2].role, "tool");
}
#[test]
fn parallel_tool_results_precede_durable_image_projection() {
let scheme = OpenAIScheme::new();
let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
let request = Request::new()
.item(Item::tool_call("call_image", "ViewImage", "{}"))
.item(Item::tool_call("call_text", "Read", "{}"))
.item(Item::tool_result_item_with_attachments(
"call_image",
"Attached image",
None,
false,
vec![crate::tool::Attachment::Image(
crate::tool::ImageAttachment::new("image/png", image),
)],
))
.item(Item::tool_result_item(
"call_text",
"Read text",
None,
false,
));
let json = serde_json::to_value(
&scheme
.build_request("gpt-4o", &request, &vision_cap())
.messages,
)
.unwrap();
assert_eq!(json[0]["role"], "assistant");
assert_eq!(json[1]["role"], "tool");
assert_eq!(json[2]["role"], "tool");
assert_eq!(json[3]["role"], "user");
assert_eq!(json[3]["content"][0]["type"], "image_url");
}
#[test]
fn durable_tool_image_is_deterministically_lowered_to_following_user_content() {
let scheme = OpenAIScheme::new();
let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
let attachment = crate::tool::Attachment::Image(crate::tool::ImageAttachment::new(
"image/png",
image.clone(),
));
let item = Item::tool_result_item_with_attachments(
"call_image",
"Attached image",
None,
false,
vec![attachment],
);
let persisted = serde_json::to_string(&item).unwrap();
assert!(persisted.contains("attachments"));
let restored: Item = serde_json::from_str(&persisted).unwrap();
let request = Request::new()
.item(Item::tool_call(
"call_image",
"ViewImage",
r#"{"path":"a.png"}"#,
))
.item(restored);
let body = scheme.build_request("gpt-4o", &request, &vision_cap());
let json = serde_json::to_value(&body.messages).unwrap();
let rebuilt = serde_json::to_value(
&scheme
.build_request("gpt-4o", &request, &vision_cap())
.messages,
)
.unwrap();
assert_eq!(rebuilt, json);
assert_eq!(json[0]["role"], "assistant");
assert_eq!(json[1]["role"], "tool");
assert_eq!(json[2]["role"], "user");
assert_eq!(json[2]["content"][0]["type"], "image_url");
assert!(
json[2]["content"][0]["image_url"]["url"]
.as_str()
.unwrap()
.starts_with("data:image/png;base64,")
);
let mut no_vision = cap();
no_vision.vision = false;
let disabled =
serde_json::to_string(&scheme.build_request("gpt-4o", &request, &no_vision)).unwrap();
assert!(!disabled.contains("data:image"));
}
}
@@ -7,14 +7,31 @@
use serde::{Serialize, Serializer};
use serde_json::Value;
use crate::llm_client::{
Request,
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
types::{ContentPart, Item, Role, ToolDefinition, parse_tool_arguments},
use crate::{
llm_client::{
Request,
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
types::{ContentPart, Item, Role, ToolDefinition, image_data_url, parse_tool_arguments},
},
tool::Attachment,
};
use super::OpenAIResponsesScheme;
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum FunctionCallOutputBody {
Text(String),
ContentItems(Vec<FunctionCallOutputContentItem>),
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum FunctionCallOutputContentItem {
InputText { text: String },
InputImage { image_url: String },
}
/// `/v1/responses` のリクエスト body。
#[derive(Debug, Serialize)]
pub(crate) struct ResponsesRequest {
@@ -91,9 +108,7 @@ pub(crate) enum InputItem {
/// function tool の結果(user 側)。
FunctionCallOutput {
call_id: String,
/// Responses は文字列 or 構造化 output を許すが、ここでは
/// `summary` + `content` を改行連結した文字列で送る。
output: String,
output: FunctionCallOutputBody,
},
/// reasoning item。`encrypted_content` があれば必ず添える。
Reasoning {
@@ -118,6 +133,7 @@ pub(crate) enum InputItem {
pub(crate) enum InputContent {
/// user / developer 側のテキスト
InputText { text: String },
/// user 側の画像
/// assistant 側のテキスト
OutputText { text: String },
}
@@ -173,7 +189,7 @@ impl OpenAIResponsesScheme {
request: &Request,
capability: &ModelCapability,
) -> ResponsesRequest {
let input = convert_items_to_input(&request.items);
let input = convert_items_to_input(&request.items, capability.vision);
let tools = request.tools.iter().map(convert_tool).collect();
// Reasoning 投影: capability が Effort / Both をサポートし、かつ
@@ -234,7 +250,7 @@ impl OpenAIResponsesScheme {
}
/// `Item` 列を `input[]` に変換する。
fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
fn convert_items_to_input(items: &[Item], supports_images: bool) -> Vec<InputItem> {
let mut out = Vec::with_capacity(items.len());
for item in items {
match item {
@@ -247,7 +263,7 @@ fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
};
let parts: Vec<InputContent> = content
.iter()
.map(|p| match p {
.map(|part| match part {
ContentPart::Text { text } => text_variant(text.clone()),
ContentPart::Refusal { refusal } => text_variant(refusal.clone()),
})
@@ -275,15 +291,33 @@ fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
call_id,
summary,
content,
attachments,
..
} => {
let text = match content {
Some(c) => format!("{summary}\n{c}"),
None => summary.clone(),
};
let output = if attachments.is_empty() {
FunctionCallOutputBody::Text(text)
} else if supports_images {
let mut parts = vec![FunctionCallOutputContentItem::InputText { text }];
parts.extend(attachments.iter().map(|attachment| {
let Attachment::Image(image) = attachment;
FunctionCallOutputContentItem::InputImage {
image_url: image_data_url(image.mime_type(), image.data()),
}
}));
FunctionCallOutputBody::ContentItems(parts)
} else {
FunctionCallOutputBody::Text(format!(
"{text}\n[{} image attachment(s) omitted: model does not support images]",
attachments.len()
))
};
out.push(InputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: text,
output,
});
}
Item::Reasoning {
@@ -690,4 +724,51 @@ mod tests {
assert_eq!(json["tools"][0]["type"], "function");
assert_eq!(json["tools"][0]["name"], "t");
}
#[test]
fn durable_tool_image_uses_function_call_output_content_items() {
let scheme = OpenAIResponsesScheme::new();
let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
let item = Item::tool_result_item_with_attachments(
"call_image",
"Attached image",
None,
false,
vec![crate::tool::Attachment::Image(
crate::tool::ImageAttachment::new("image/png", image),
)],
);
let persisted = serde_json::to_string(&item).unwrap();
let restored: Item = serde_json::from_str(&persisted).unwrap();
let req = Request::new()
.item(Item::tool_call(
"call_image",
"ViewImage",
r#"{"path":"a.png"}"#,
))
.item(restored);
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
let json = serde_json::to_value(&body).unwrap();
assert_eq!(json["input"][1]["type"], "function_call_output");
assert_eq!(json["input"].as_array().unwrap().len(), 2);
assert_eq!(json["input"][1]["output"][0]["type"], "input_text");
assert_eq!(json["input"][1]["output"][1]["type"], "input_image");
assert!(
json["input"][1]["output"][1]["image_url"]
.as_str()
.unwrap()
.starts_with("data:image/png;base64,")
);
let rebuilt =
serde_json::to_value(scheme.build_request("gpt-5", &req, &cap_with_reasoning()))
.unwrap();
assert_eq!(rebuilt["input"], json["input"]);
let mut no_vision = cap_with_reasoning();
no_vision.vision = false;
let disabled =
serde_json::to_string(&scheme.build_request("gpt-5", &req, &no_vision)).unwrap();
assert!(!disabled.contains("data:image"));
}
}
+23 -1
View File
@@ -9,12 +9,19 @@
use std::{fmt, sync::Arc};
use crate::tool::Attachment;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
fn is_false(value: &bool) -> bool {
!*value
}
pub(crate) fn image_data_url(media_type: &str, data: &[u8]) -> String {
let encoded = base64::engine::general_purpose::STANDARD.encode(data);
format!("data:{media_type};base64,{encoded}")
}
// ============================================================================
// Item - The core unit of conversation
// ============================================================================
@@ -117,6 +124,9 @@ pub enum Item {
/// Whether the tool result represents an execution error.
#[serde(default, skip_serializing_if = "is_false")]
is_error: bool,
/// Durable binary details (removed with `content` by normal pruning).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<Attachment>,
},
/// Reasoning/thinking item
@@ -250,6 +260,17 @@ impl Item {
summary: impl Into<String>,
content: Option<String>,
is_error: bool,
) -> Self {
Self::tool_result_item_with_attachments(call_id, summary, content, is_error, Vec::new())
}
/// Create a tool result item with durable, prunable structured attachments.
pub fn tool_result_item_with_attachments(
call_id: impl Into<String>,
summary: impl Into<String>,
content: Option<String>,
is_error: bool,
attachments: Vec<Attachment>,
) -> Self {
Self::ToolResult {
id: None,
@@ -257,6 +278,7 @@ impl Item {
summary: summary.into(),
content,
is_error,
attachments,
}
}
@@ -461,7 +483,7 @@ impl ContentPart {
}
}
/// Get the text content regardless of type
/// Get a textual projection of the content part.
pub fn as_text(&self) -> &str {
match self {
Self::Text { text } => text,
+48 -9
View File
@@ -110,25 +110,30 @@ impl Default for PruneConfig {
}
}
/// Set `content = None` on each `Item::ToolResult` at the given indices.
/// Remove detailed text and attachments from each `Item::ToolResult` at the given indices.
///
/// Returns the number of items that were actually modified — items that
/// are already content-less are counted as 0. Intended for use on a
/// request-context clone (never on a persistent history).
/// The mandatory summary remains. Returns the number of items that were actually
/// modified — results that already contain no detail are counted as 0. Intended
/// for use on a request-context clone (never on a persistent history).
pub fn project(items: &mut [Item], indices: &[usize]) -> usize {
let mut count = 0;
for &i in indices {
if let Item::ToolResult { content, .. } = &mut items[i]
&& content.is_some()
if let Item::ToolResult {
content,
attachments,
..
} = &mut items[i]
&& (content.is_some() || !attachments.is_empty())
{
*content = None;
attachments.clear();
count += 1;
}
}
count
}
/// Indices of `Item::ToolResult { content: Some(_), .. }` that lie before
/// Indices of detailed `Item::ToolResult` values that lie before
/// the suffix protected by `protected_tokens`. Pure: does not mutate `items`.
///
/// Returns an empty vector when token estimates are unavailable (`NoData`) or
@@ -159,8 +164,10 @@ pub fn evaluate_candidates(
.enumerate()
.filter_map(|(i, item)| match item {
Item::ToolResult {
content: Some(_), ..
} => Some(i),
content,
attachments,
..
} if content.is_some() || !attachments.is_empty() => Some(i),
_ => None,
})
.collect();
@@ -373,6 +380,38 @@ mod tests {
}
}
#[test]
fn project_drops_image_detail_but_keeps_summary_and_persistent_source() {
let original = vec![Item::tool_result_item_with_attachments(
"call_image",
"Attached image/png image (12 bytes)",
None,
false,
vec![crate::tool::Attachment::Image(
crate::tool::ImageAttachment::new("image/png", b"image-body".to_vec()),
)],
)];
let mut request_context = original.clone();
let estimates = uniform_estimates(&original, 100);
assert_eq!(prunable_indices(&original, 0, &estimates), vec![0]);
assert_eq!(project(&mut request_context, &[0]), 1);
assert!(matches!(
&request_context[0],
Item::ToolResult {
summary,
content: None,
attachments,
..
} if summary == "Attached image/png image (12 bytes)" && attachments.is_empty()
));
assert!(matches!(
&original[0],
Item::ToolResult { attachments, .. } if attachments.len() == 1
));
}
#[test]
fn project_skips_already_pruned_items() {
// indices points at an item whose content is already None.
+85 -7
View File
@@ -3,11 +3,11 @@
//! Traits for defining tools callable by LLM.
//! Usually auto-implemented using the `#[tool]` macro.
use std::collections::HashMap;
use std::sync::Arc;
use std::{collections::HashMap, fmt, sync::Arc};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
use serde_json::Value;
use thiserror::Error;
@@ -89,19 +89,90 @@ pub(crate) fn truncate_content(content: &mut String, limit: usize) {
content.push_str(&suffix_template.replace("%BYTES%", &dropped.to_string()));
}
#[derive(Clone, PartialEq, Eq)]
pub struct ImageAttachment {
mime_type: String,
data: Arc<[u8]>,
}
impl ImageAttachment {
pub fn new(mime_type: impl Into<String>, data: impl Into<Arc<[u8]>>) -> Self {
Self {
mime_type: mime_type.into(),
data: data.into(),
}
}
pub fn mime_type(&self) -> &str {
&self.mime_type
}
pub fn data(&self) -> &[u8] {
&self.data
}
}
impl fmt::Debug for ImageAttachment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ImageAttachment")
.field("mime_type", &self.mime_type)
.field("bytes", &self.data.len())
.finish()
}
}
#[derive(Serialize, Deserialize)]
struct ImageAttachmentWire {
mime_type: String,
data: String,
}
impl Serialize for ImageAttachment {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
ImageAttachmentWire {
mime_type: self.mime_type.clone(),
data: STANDARD.encode(self.data.as_ref()),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for ImageAttachment {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = ImageAttachmentWire::deserialize(deserializer)?;
let data = STANDARD.decode(wire.data).map_err(D::Error::custom)?;
Ok(Self::new(wire.mime_type, data))
}
}
/// Durable binary detail emitted by a tool and handled by normal ToolResult pruning.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
pub enum Attachment {
Image(ImageAttachment),
}
/// Tool execution result.
///
/// Every output has a mandatory `summary` (1-2 lines) that persists in
/// conversation history even after pruning. The optional `content` carries
/// full details and is removed by the Prune mechanism when the context
/// grows too large.
/// conversation history even after pruning. Optional text and binary details are
/// committed to history and may later be omitted only by normal ToolResult pruning.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolOutput {
/// Short summary (1-2 lines). Always remains in history.
pub summary: String,
/// Detailed output. Removed by Prune when old enough.
/// Detailed text output. Removed by Prune when old enough.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Durable binary details handled by the same pruning lifecycle as `content`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<Attachment>,
}
impl From<String> for ToolOutput {
@@ -110,6 +181,7 @@ impl From<String> for ToolOutput {
ToolOutput {
summary: s,
content: None,
attachments: Vec::new(),
}
} else {
let lines = s.lines().count();
@@ -118,6 +190,7 @@ impl From<String> for ToolOutput {
ToolOutput {
summary,
content: Some(s),
attachments: Vec::new(),
}
}
}
@@ -364,6 +437,9 @@ pub struct ToolResult {
/// Whether this is an error
#[serde(default)]
pub is_error: bool,
/// Durable binary details (prunable with `content`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<Attachment>,
}
impl ToolResult {
@@ -374,6 +450,7 @@ impl ToolResult {
summary: output.summary,
content: output.content,
is_error: false,
attachments: output.attachments,
}
}
@@ -384,6 +461,7 @@ impl ToolResult {
summary: message.into(),
content: None,
is_error: true,
attachments: Vec::new(),
}
}
}
+10
View File
@@ -83,8 +83,12 @@ pub struct FeatureConfigPartial {
#[serde(default)]
pub web: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub image: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub sub_worker: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub flow: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub worker: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub objective: Option<FeatureFlagConfigPartial>,
@@ -102,11 +106,13 @@ impl FeatureConfigPartial {
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::merge),
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
image: merge_option(self.image, other.image, FeatureFlagConfigPartial::merge),
sub_worker: merge_option(
self.sub_worker,
other.sub_worker,
FeatureFlagConfigPartial::merge,
),
flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::merge),
worker: merge_option(self.worker, other.worker, FeatureFlagConfigPartial::merge),
objective: merge_option(
self.objective,
@@ -186,10 +192,12 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.map(MemoryFeatureConfig::from)
.unwrap_or_default(),
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
sub_worker: value
.sub_worker
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
flow: value.flow.map(FeatureFlagConfig::from).unwrap_or_default(),
worker: value
.worker
.map(FeatureFlagConfig::from)
@@ -278,7 +286,9 @@ impl From<FeatureConfig> for FeatureConfigPartial {
task: Some(value.task.into()),
memory: Some(value.memory.into()),
web: Some(value.web.into()),
image: Some(value.image.into()),
sub_worker: Some(value.sub_worker.into()),
flow: Some(value.flow.into()),
worker: Some(value.worker.into()),
objective: Some(value.objective.into()),
manage_workdir: Some(value.manage_workdir.into()),
+6
View File
@@ -111,8 +111,12 @@ pub struct FeatureConfig {
#[serde(default)]
pub web: FeatureFlagConfig,
#[serde(default)]
pub image: FeatureFlagConfig,
#[serde(default)]
pub sub_worker: FeatureFlagConfig,
#[serde(default)]
pub flow: FeatureFlagConfig,
#[serde(default)]
pub worker: FeatureFlagConfig,
#[serde(default)]
pub objective: FeatureFlagConfig,
@@ -130,7 +134,9 @@ impl Default for FeatureConfig {
task: FeatureFlagConfig::disabled(),
memory: MemoryFeatureConfig::disabled(),
web: FeatureFlagConfig::disabled(),
image: FeatureFlagConfig::disabled(),
sub_worker: FeatureFlagConfig::disabled(),
flow: FeatureFlagConfig::disabled(),
worker: FeatureFlagConfig::disabled(),
objective: FeatureFlagConfig::disabled(),
manage_workdir: FeatureFlagConfig::disabled(),
+7 -2
View File
@@ -912,7 +912,7 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
true,
true,
true,
false,
true,
);
Some(value)
}
@@ -962,6 +962,7 @@ fn builtin_base_profile_artifact() -> serde_json::Value {
"task": { "enabled": true },
"memory": { "enabled": true },
"web": { "enabled": true },
"image": { "enabled": true },
"sub_worker": { "enabled": true },
"worker": { "enabled": false },
"objective": { "enabled": true },
@@ -998,7 +999,9 @@ fn apply_role_profile(
value["feature"]["task"] = serde_json::json!({ "enabled": task });
value["feature"]["memory"] = serde_json::json!({ "enabled": memory });
value["feature"]["web"] = serde_json::json!({ "enabled": web });
value["feature"]["image"] = serde_json::json!({ "enabled": true });
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
value["feature"]["worker"] =
serde_json::json!({ "enabled": matches!(slug, "companion" | "orchestrator") });
value["feature"]["manage_workdir"] = serde_json::json!({ "enabled": slug == "orchestrator" });
@@ -1530,7 +1533,8 @@ mod tests {
let coder = resolve("coder");
assert!(coder.feature.task.enabled);
assert!(!coder.feature.sub_worker.enabled);
assert!(coder.feature.sub_worker.enabled);
assert!(coder.feature.flow.enabled);
assert!(!coder.feature.worker.enabled);
assert!(coder.scope.allow.is_empty());
assert!(coder.delegation_scope.allow.is_empty());
@@ -1548,6 +1552,7 @@ mod tests {
let reviewer = resolve("reviewer");
assert!(reviewer.feature.task.enabled);
assert!(!reviewer.feature.sub_worker.enabled);
assert!(!reviewer.feature.flow.enabled);
assert!(!reviewer.feature.worker.enabled);
assert!(reviewer.feature.ticket.enabled);
assert!(reviewer.feature.ticket.enabled);
+1
View File
@@ -85,6 +85,7 @@ impl Tool for WriteExtractedTool {
Ok(ToolOutput {
summary,
content: None,
attachments: Vec::new(),
})
}
}
+2
View File
@@ -8,8 +8,10 @@ license.workspace = true
default = ["stream"]
stream = ["dep:tokio"]
typescript = ["dep:ts-rs"]
json-schema = ["dep:schemars"]
[dependencies]
schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["io-util"], optional = true }
+62
View File
@@ -31,6 +31,15 @@ pub enum Method {
Run {
input: Vec<Segment>,
},
/// Runtime-internal Run carrying an opaque correlation id that is committed
/// with the resulting UserInput entry. This variant is not serializable on
/// the public Client → Worker protocol.
#[serde(skip)]
#[cfg_attr(feature = "typescript", ts(skip))]
RunTracked {
input: Vec<Segment>,
submission_id: String,
},
/// Human-readable text injected into the target Worker's LLM context
/// as a non-blocking system message. `auto_run` controls whether an
/// idle target is kicked into `RunForNotification`; weak notifications
@@ -183,6 +192,7 @@ impl WorkerEvent {
/// the dropped intent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Segment {
/// Free-form text. The fallback every client can produce.
@@ -202,6 +212,11 @@ pub enum Segment {
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
/// `@<path>` placeholder either way.
FileRef { path: String },
/// Source-qualified request for the host to start a Flow before committing
/// this user input. Runtime resolves it through Workspace authority and
/// replaces it with the entered state's instructions before Worker-side
/// input resolution.
Flow { selector: String },
/// Unknown variant from a newer client. Worker treats this as an
/// unresolved input — surfaces an alert and inserts a placeholder.
/// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`.
@@ -236,6 +251,11 @@ impl Segment {
out.push('@');
out.push_str(path);
}
Segment::Flow { selector } => {
out.push_str("[Flow: ");
out.push_str(selector);
out.push(']');
}
Segment::Unknown => {}
}
}
@@ -900,6 +920,48 @@ mod tests {
}
}
#[test]
fn method_run_flow_segment_roundtrip() {
let method = Method::Run {
input: vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::text("Ticket context"),
],
};
let json = serde_json::to_string(&method).unwrap();
assert!(json.contains(r#""kind":"flow""#));
assert!(json.contains(r#""selector":"builtin:coder-review""#));
let decoded = serde_json::from_str::<Method>(&json).unwrap();
assert!(matches!(
decoded,
Method::Run { input }
if matches!(
input.as_slice(),
[
Segment::Flow { selector },
Segment::Text { content }
] if selector == "builtin:coder-review" && content == "Ticket context"
)
));
}
#[test]
fn runtime_tracked_run_is_not_public_protocol_json() {
let method = Method::RunTracked {
input: vec![Segment::text("private")],
submission_id: "submission-1".to_string(),
};
assert!(serde_json::to_string(&method).is_err());
assert!(
serde_json::from_str::<Method>(
r#"{"method":"run_tracked","input":[],"submission_id":"forged"}"#,
)
.is_err()
);
}
#[test]
fn segment_unknown_variant_decodes_as_unknown() {
// A future client sends a segment kind this Worker has never heard of.
+4 -1
View File
@@ -1600,9 +1600,12 @@ fn tool_kind(name: &str) -> &'static str {
"WebFetch" | "WebSearch" => "web",
"SubWorkerSpawn"
| "SubWorkerSend"
| "SubWorkerReadOutput"
| "SubWorkerList"
| "SubWorkerStop"
| "ListWorkerSessions"
| "ViewSessionOverview"
| "SearchSessionEntries"
| "ReadSessionEntry"
| "WorkerList"
| "WorkerSpawn"
| "WorkerStop"
+1
View File
@@ -6,6 +6,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
base64.workspace = true
llm-engine = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
+1 -1
View File
@@ -50,7 +50,7 @@ pub use segment::{
fork_at, restore, restore_by_segment, save_config_changed, save_delta, save_extension,
save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input,
};
pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, collect_state};
pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension, collect_state};
pub use store::{Store, StoreError};
pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_worker_event};
pub use worker_metadata::{
+93 -2
View File
@@ -12,13 +12,36 @@
//! `Reasoning::encrypted_content` is preserved because OpenAI Responses ZDR
//! requires it on stateless re-send.
use llm_engine::llm_client::types::{ContentPart, Item, Role};
use serde::{Deserialize, Serialize};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use llm_engine::{
llm_client::types::{ContentPart, Item, Role},
tool::{Attachment, ImageAttachment},
};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
fn is_false(value: &bool) -> bool {
!*value
}
mod base64_bytes {
use super::*;
pub fn serialize<S>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&STANDARD.encode(data))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let encoded = String::deserialize(deserializer)?;
STANDARD.decode(encoded).map_err(D::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LoggedItem {
@@ -36,6 +59,8 @@ pub enum LoggedItem {
summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<LoggedAttachment>,
#[serde(default, skip_serializing_if = "is_false")]
is_error: bool,
},
@@ -67,6 +92,16 @@ pub enum LoggedContentPart {
Refusal { refusal: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LoggedAttachment {
Image {
mime_type: String,
#[serde(with = "base64_bytes")]
data: Vec<u8>,
},
}
// ---------------------------------------------------------------------------
// Item ↔ LoggedItem
// ---------------------------------------------------------------------------
@@ -92,12 +127,14 @@ impl From<&Item> for LoggedItem {
call_id,
summary,
content,
attachments,
is_error,
..
} => Self::ToolResult {
call_id: call_id.clone(),
summary: summary.clone(),
content: content.clone(),
attachments: attachments.iter().map(LoggedAttachment::from).collect(),
is_error: *is_error,
},
Item::Reasoning {
@@ -146,6 +183,7 @@ impl From<LoggedItem> for Item {
call_id,
summary,
content,
attachments,
is_error,
} => Item::ToolResult {
id: None,
@@ -153,6 +191,7 @@ impl From<LoggedItem> for Item {
summary,
content,
is_error,
attachments: attachments.into_iter().map(Attachment::from).collect(),
},
LoggedItem::Reasoning {
text,
@@ -229,6 +268,27 @@ impl From<LoggedContentPart> for ContentPart {
}
}
impl From<&Attachment> for LoggedAttachment {
fn from(attachment: &Attachment) -> Self {
match attachment {
Attachment::Image(image) => Self::Image {
mime_type: image.mime_type().to_string(),
data: image.data().to_vec(),
},
}
}
}
impl From<LoggedAttachment> for Attachment {
fn from(attachment: LoggedAttachment) -> Self {
match attachment {
LoggedAttachment::Image { mime_type, data } => {
Self::Image(ImageAttachment::new(mime_type, data))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -370,6 +430,37 @@ mod tests {
}
}
#[test]
fn tool_result_persistence_round_trips_binary_attachments() {
let original = Item::tool_result_item_with_attachments(
"call_image",
"attached",
None,
false,
vec![llm_engine::tool::Attachment::Image(
llm_engine::tool::ImageAttachment::new(
"image/png",
std::sync::Arc::<[u8]>::from(&b"secret-image-body"[..]),
),
)],
);
let logged: LoggedItem = (&original).into();
let json = serde_json::to_string(&logged).unwrap();
assert!(json.contains("attachments"));
assert!(json.contains("c2VjcmV0LWltYWdlLWJvZHk="));
let restored: LoggedItem = serde_json::from_str(&json).unwrap();
match Item::from(restored) {
Item::ToolResult { attachments, .. } => assert!(matches!(
attachments.as_slice(),
[Attachment::Image(image)]
if image.mime_type() == "image/png"
&& image.data() == b"secret-image-body"
)),
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn message_serialization_uses_kind_tag() {
let logged: LoggedItem = (&Item::assistant_message("hi")).into();
+13
View File
@@ -183,6 +183,18 @@ pub fn save_user_input(
session_id: SessionId,
segment_id: SegmentId,
segments: Vec<Segment>,
) -> Result<(), StoreError> {
save_user_input_with_extensions(store, session_id, segment_id, segments, Vec::new())
}
/// Atomically persist one typed user submission and Runtime-owned session
/// extensions in the same log record.
pub fn save_user_input_with_extensions(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
segments: Vec<Segment>,
extensions: Vec<segment_log::SessionExtension>,
) -> Result<(), StoreError> {
append_entry(
store,
@@ -191,6 +203,7 @@ pub fn save_user_input(
LogEntry::UserInput {
ts: segment_log::now_millis(),
segments,
extensions,
},
)
}
+91 -2
View File
@@ -31,6 +31,21 @@ use crate::system_item::SystemItem;
/// - `RunCompleted` / `RunErrored` — marks end of a `run()` or `resume()` call
/// - `PausedTurnAbandoned` — explicit abandon/cancel of a paused interrupted turn
/// - `ConfigChanged` — `RequestConfig` mutation
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionExtension {
pub domain: String,
pub payload: serde_json::Value,
}
impl SessionExtension {
pub fn new(domain: impl Into<String>, payload: serde_json::Value) -> Self {
Self {
domain: domain.into(),
payload,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LogEntry {
@@ -80,7 +95,15 @@ pub enum LogEntry {
/// file refs) on segment restore.
/// Replay flattens these into a `Item::user_message` for the worker
/// history; the worker layer never sees segments directly.
UserInput { ts: u64, segments: Vec<Segment> },
UserInput {
ts: u64,
segments: Vec<Segment>,
/// Typed durable state committed atomically with this input record.
/// Runtime-owned Flow invocation uses this to avoid a Backend-instance
/// commit that can get ahead of Worker history.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extensions: Vec<SessionExtension>,
},
/// One assistant-side item appended to history — assistant message,
/// reasoning, or tool call. Singular: one entry per history item so
@@ -243,10 +266,19 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
// log ends first, restore must treat the turn as interrupted.
state.last_run_interrupted = true;
}
LogEntry::UserInput { segments, .. } => {
LogEntry::UserInput {
segments,
extensions,
..
} => {
let text = Segment::flatten_to_text(segments);
state.history.push(Item::user_message(text));
state.user_segments.push(segments.clone());
state.extensions.extend(
extensions
.iter()
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
);
}
LogEntry::AssistantItem { item, .. } => {
state.history.push(Item::from(item.clone()));
@@ -350,6 +382,7 @@ mod tests {
},
LogEntry::UserInput {
ts: 2000,
extensions: vec![],
segments: vec![Segment::text("Hello")],
},
LogEntry::AssistantItem {
@@ -389,6 +422,7 @@ mod tests {
},
LogEntry::UserInput {
ts: 2001,
extensions: vec![],
segments: vec![Segment::text("run a tool")],
},
LogEntry::AssistantItem {
@@ -414,6 +448,7 @@ mod tests {
},
LogEntry::UserInput {
ts: 2000,
extensions: vec![],
segments: vec![Segment::text("Check weather")],
},
LogEntry::AssistantItem {
@@ -438,6 +473,36 @@ mod tests {
assert!(state.history[2].is_tool_result());
}
#[test]
fn replay_restores_durable_tool_image_detail() {
let entry = LogEntry::ToolResult {
ts: 3500,
item: Item::tool_result_item_with_attachments(
"call_image",
"attached",
None,
false,
vec![llm_engine::tool::Attachment::Image(
llm_engine::tool::ImageAttachment::new("image/png", b"durable-image".to_vec()),
)],
)
.into(),
};
let persisted = serde_json::to_string(&entry).unwrap();
let restored_entry: LogEntry = serde_json::from_str(&persisted).unwrap();
let state = collect_state(&[restored_entry]);
assert!(matches!(
&state.history[0],
Item::ToolResult { attachments, .. }
if matches!(
attachments.as_slice(),
[llm_engine::tool::Attachment::Image(image)]
if image.data() == b"durable-image"
)
));
}
#[test]
fn replay_config_changed() {
let state = collect_state(&[
@@ -472,6 +537,7 @@ mod tests {
},
LogEntry::UserInput {
ts: 2000,
extensions: vec![],
segments: vec![Segment::text("hi")],
},
LogEntry::LlmUsage {
@@ -519,6 +585,7 @@ mod tests {
},
LogEntry::UserInput {
ts: 2000,
extensions: vec![],
segments: vec![Segment::text("hi")],
},
]);
@@ -595,6 +662,7 @@ mod tests {
},
LogEntry::UserInput {
ts: 101,
extensions: vec![],
segments: vec![Segment::text("hi")],
},
LogEntry::TurnEnd {
@@ -705,6 +773,26 @@ mod tests {
}
}
#[test]
fn user_input_extensions_restore_with_the_same_committed_input() {
let segments = vec![Segment::text("Flow instructions"), Segment::text("Ticket")];
let entry = LogEntry::UserInput {
ts: 9999,
segments: segments.clone(),
extensions: vec![SessionExtension::new(
"flow.runtime.v1",
serde_json::json!({ "state": "implement", "revision": 0 }),
)],
};
let json = serde_json::to_string(&entry).unwrap();
let decoded: LogEntry = serde_json::from_str(&json).unwrap();
let state = collect_state(&[decoded]);
assert_eq!(state.user_segments, vec![segments]);
assert_eq!(state.extensions.len(), 1);
assert_eq!(state.extensions[0].0, "flow.runtime.v1");
assert_eq!(state.extensions[0].1["state"], "implement");
}
/// Mixed segments survive a JSON round-trip through `LogEntry::UserInput`,
/// and `collect_state` derives `Item::user_message` from the flattened
/// text while preserving the original segments separately. This covers
@@ -727,6 +815,7 @@ mod tests {
];
let entry = LogEntry::UserInput {
ts: 4242,
extensions: vec![],
segments: segments.clone(),
};
// JSON round-trip preserves the variant byte-for-byte.
@@ -36,6 +36,7 @@ fn round_trip_write_and_read() {
},
LogEntry::UserInput {
ts: 2000,
extensions: vec![],
segments: vec![protocol::Segment::text("Hello")],
},
LogEntry::AssistantItem {
@@ -214,6 +215,7 @@ fn read_entry_count_matches_append_tally() {
},
LogEntry::UserInput {
ts: 2000,
extensions: vec![],
segments: vec![protocol::Segment::text("Hello")],
},
];
@@ -253,6 +255,7 @@ fn unterminated_utf8_tail_is_ignored_and_replaced_on_append() {
let next = LogEntry::UserInput {
ts: 2,
extensions: vec![],
segments: vec![protocol::Segment::text("recovered")],
};
store.append(sid, segid, &next).unwrap();
@@ -453,6 +453,7 @@ async fn session_auto_forks_on_conflict() {
// Simulate another Worker writing to the same segment behind our back.
let extra_entry = LogEntry::UserInput {
ts: 9999,
extensions: vec![],
segments: vec![protocol::Segment::text("Interloper")],
};
store.append(sid, original_segid, &extra_entry).unwrap();
+1
View File
@@ -1703,6 +1703,7 @@ fn json_output(summary: String, value: impl Serialize) -> ToolOutput {
ToolOutput {
summary,
content: Some(serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())),
attachments: Vec::new(),
}
}
+5 -1
View File
@@ -96,7 +96,11 @@ impl Tool for BashTool {
} else {
Some(output.content)
};
Ok(ToolOutput { summary, content })
Ok(ToolOutput {
summary,
content,
attachments: Vec::new(),
})
}
}
+1
View File
@@ -85,6 +85,7 @@ impl Tool for EditTool {
Ok(ToolOutput {
summary,
content: Some(preview),
attachments: Vec::new(),
})
}
}
+1
View File
@@ -69,6 +69,7 @@ impl Tool for GlobTool {
Ok(ToolOutput {
summary,
content: (!body.is_empty()).then_some(body),
attachments: Vec::new(),
})
}
}
+1
View File
@@ -120,6 +120,7 @@ impl Tool for GrepTool {
Ok(ToolOutput {
summary,
content: (!result.output.is_empty()).then_some(result.output),
attachments: Vec::new(),
})
}
}
+13
View File
@@ -17,6 +17,7 @@ mod edit;
mod glob;
mod grep;
mod read;
mod view_image;
mod web;
mod write;
@@ -27,6 +28,7 @@ pub use glob::glob_tool;
pub use grep::grep_tool;
pub use read::read_tool;
pub use tracker::Tracker;
pub use view_image::view_image_tool;
pub use web::{web_fetch_tool, web_search_tool};
pub use write::write_tool;
@@ -62,6 +64,17 @@ pub fn core_builtin_tools(
tools
}
pub fn read_only_builtin_tools(
session: workdir::WorkdirSessionHandle,
) -> Vec<llm_engine::tool::ToolDefinition> {
debug_assert_eq!(
session.capabilities(),
workdir::WorkdirSessionCapabilities::READ_ONLY,
"read-only tool projection requires a read-only Workdir session"
);
core_builtin_tools(session, Tracker::new(), std::path::PathBuf::new())
}
pub fn web_builtin_tools(
web_config: Option<manifest::WebConfig>,
) -> Vec<llm_engine::tool::ToolDefinition> {
+1
View File
@@ -86,6 +86,7 @@ impl Tool for ReadTool {
Ok(ToolOutput {
summary,
content: Some(rendered.body),
attachments: Vec::new(),
})
}
}
+123
View File
@@ -0,0 +1,123 @@
//! `ViewImage` tool — attach a bounded image from the scoped Workdir.
use std::sync::Arc;
use async_trait::async_trait;
use llm_engine::tool::{
Attachment, ImageAttachment, Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput,
};
use serde::Deserialize;
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
use crate::error::ToolsError;
/// Maximum image body accepted for one model request.
pub const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
const DESCRIPTION: &str = "Attach an image from the bound Workdir to the next model request. \
The path must be logical and Workdir-relative. Supported formats: PNG, JPEG, GIF, and WebP.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ViewImageParams {
/// Logical path relative to the bound Workdir root.
path: String,
}
struct ViewImageTool {
session: WorkdirSessionHandle,
}
#[async_trait]
impl Tool for ViewImageTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let input: ViewImageParams = serde_json::from_str(input_json).map_err(|error| {
ToolError::InvalidArgument(format!("invalid ViewImage input: {error}"))
})?;
let path = WorkdirPath::new(&input.path).map_err(ToolsError::from)?;
let result = self
.session
.read(ReadRequest {
path: path.clone(),
offset: 0,
limit: usize::MAX,
// The scoped provider enforces this cap while reading, rather
// than allocating an unbounded binary body first.
max_bytes: MAX_IMAGE_BYTES + 1,
})
.await
.map_err(ToolsError::from)?;
if result.truncated || result.bytes.len() > MAX_IMAGE_BYTES {
return Err(ToolError::InvalidArgument(format!(
"image exceeds the {MAX_IMAGE_BYTES}-byte limit"
)));
}
let mime_type = detect_image_mime(&result.bytes).ok_or_else(|| {
ToolError::InvalidArgument(
"unsupported image; expected PNG, JPEG, GIF, or WebP bytes".to_string(),
)
})?;
let bytes = result.bytes.len();
Ok(ToolOutput {
summary: format!("Attached image {path} ({mime_type}, {bytes} bytes)"),
content: None,
attachments: vec![Attachment::Image(ImageAttachment::new(
mime_type,
Arc::<[u8]>::from(result.bytes),
))],
})
}
}
pub fn view_image_tool(session: WorkdirSessionHandle) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(ViewImageParams);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("ViewImage")
.description(DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(ViewImageTool {
session: session.clone(),
});
(meta, tool)
})
}
fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some("image/png")
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
Some("image/jpeg")
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
Some("image/gif")
} else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
Some("image/webp")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_supported_image_signatures_without_trusting_extensions() {
assert_eq!(
detect_image_mime(b"\x89PNG\r\n\x1a\nbody"),
Some("image/png")
);
assert_eq!(
detect_image_mime(&[0xff, 0xd8, 0xff, 0xe0]),
Some("image/jpeg")
);
assert_eq!(detect_image_mime(b"GIF89abody"), Some("image/gif"));
assert_eq!(detect_image_mime(b"RIFF1234WEBPbody"), Some("image/webp"));
assert_eq!(detect_image_mime(b"not an image"), None);
}
}
+1
View File
@@ -1743,6 +1743,7 @@ fn json_output(value: Value) -> ToolOutput {
ToolOutput {
summary,
content: Some(content),
attachments: Vec::new(),
}
}
+1
View File
@@ -76,6 +76,7 @@ impl Tool for WriteTool {
Ok(ToolOutput {
summary,
content: None,
attachments: Vec::new(),
})
}
}
+29 -1
View File
@@ -11,7 +11,7 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolMeta};
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
use serde_json::json;
use tempfile::TempDir;
use tools::{Tracker, core_builtin_tools};
use tools::{Tracker, core_builtin_tools, view_image_tool};
use workdir::{LocalWorkdirSession, WorkdirSessionHandle};
fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
@@ -100,6 +100,34 @@ fn meta_has_description_and_schema() {
}
}
#[tokio::test]
async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let png = b"\x89PNG\r\n\x1a\nprivate-image-body";
std::fs::write(dir.path().join("image.png"), png).unwrap();
let definition = view_image_tool(session);
let (_meta, tool) = definition();
let output = call(&tool, json!({ "path": "image.png" })).await;
assert_eq!(output.attachments.len(), 1);
let llm_engine::tool::Attachment::Image(image) = &output.attachments[0];
assert_eq!(image.mime_type(), "image/png");
assert_eq!(image.data(), png);
let serialized = serde_json::to_string(&output).unwrap();
assert!(!serialized.contains("private-image-body"));
assert!(serialized.contains("attachments"));
let restored: llm_engine::tool::ToolOutput = serde_json::from_str(&serialized).unwrap();
let llm_engine::tool::Attachment::Image(restored_image) = &restored.attachments[0];
assert_eq!(restored_image.data(), png);
let escaped = call_err(&tool, json!({ "path": "../outside.png" })).await;
assert!(escaped.to_string().contains("scope") || escaped.to_string().contains("path"));
}
#[tokio::test]
async fn read_then_edit_then_read_roundtrip() {
let (dir, _spill, reg) = setup();
+27 -2
View File
@@ -46,11 +46,23 @@ impl FileRefAtom {
}
}
#[derive(Debug, Clone)]
pub struct FlowRefAtom {
pub selector: String,
}
impl FlowRefAtom {
pub fn label(&self) -> String {
format!("[Flow: {}]", self.selector)
}
}
#[derive(Debug, Clone)]
pub enum Atom {
Char(char),
Paste(PasteRef),
FileRef(FileRefAtom),
FlowRef(FlowRefAtom),
}
impl Atom {
@@ -61,6 +73,7 @@ impl Atom {
Atom::Char(_) => None,
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
Atom::FlowRef(r) => Some((Style::default().fg(Color::Yellow), r.label())),
}
}
}
@@ -89,7 +102,7 @@ enum WordKind {
fn atom_class(atom: &Atom) -> AtomClass {
match atom {
Atom::Char(c) => char_class(*c),
Atom::Paste(_) | Atom::FileRef(_) => AtomClass::Chip,
Atom::Paste(_) | Atom::FileRef(_) | Atom::FlowRef(_) => AtomClass::Chip,
}
}
@@ -181,6 +194,11 @@ impl InputBuffer {
self.atoms
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
}
protocol::Segment::Flow { selector } => {
self.atoms.push(Atom::FlowRef(FlowRefAtom {
selector: selector.clone(),
}));
}
protocol::Segment::Unknown => {
self.atoms
.extend("[unknown input segment]".chars().map(Atom::Char));
@@ -208,6 +226,7 @@ impl InputBuffer {
Atom::Char(c) => text.push(*c),
Atom::Paste(paste) => text.push_str(&paste.content),
Atom::FileRef(file) => text.push_str(&file.path),
Atom::FlowRef(flow) => text.push_str(&flow.selector),
}
}
text
@@ -484,6 +503,12 @@ impl InputBuffer {
path: r.path.clone(),
});
}
Atom::FlowRef(r) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::Flow {
selector: r.selector.clone(),
});
}
}
}
if !buf.is_empty() {
@@ -1194,7 +1219,7 @@ mod word_motion_tests {
for a in &buf.atoms {
match a {
Atom::Char(c) => out.push(*c),
Atom::Paste(_) | Atom::FileRef(_) => out.push_str("<P>"),
Atom::Paste(_) | Atom::FileRef(_) | Atom::FlowRef(_) => out.push_str("<P>"),
}
}
out
+5
View File
@@ -1097,6 +1097,10 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) {
format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"),
),
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
Segment::Flow { selector } => (
Style::default().fg(Color::Yellow),
format!("[Flow: {selector}]"),
),
Segment::Unknown => (fallback, "[unknown segment]".to_owned()),
}
}
@@ -1111,6 +1115,7 @@ fn segment_display_text(seg: &Segment) -> String {
id, chars, lines, ..
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
Segment::FileRef { path } => format!("@{path}"),
Segment::Flow { selector } => format!("[Flow: {selector}]"),
Segment::Unknown => "[unknown segment]".to_owned(),
}
}
+1
View File
@@ -1233,6 +1233,7 @@ mod tests {
&LogEntry::UserInput {
ts,
segments: vec![protocol::Segment::text(text)],
extensions: vec![],
},
)
.unwrap();
+100
View File
@@ -11,6 +11,7 @@ mod operation;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
@@ -158,6 +159,105 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
pub type WorkdirSessionHandle = Arc<dyn WorkdirSession>;
/// Ephemeral least-authority view over an existing Workdir session.
///
/// The wrapper exposes only stat/read/list/glob/grep and never forwards write,
/// edit, command, or close authority to the underlying Worker session. Closing
/// the wrapper is terminal for the view but deliberately leaves the owner's
/// source session open.
#[derive(Debug)]
pub struct ReadOnlyWorkdirSession {
source: WorkdirSessionHandle,
closed: AtomicBool,
}
impl ReadOnlyWorkdirSession {
pub fn new(source: WorkdirSessionHandle) -> Self {
Self {
source,
closed: AtomicBool::new(false),
}
}
fn ensure_open(&self) -> Result<(), WorkdirError> {
if self.closed.load(Ordering::Acquire) {
Err(WorkdirError::Unavailable(
"read-only Workdir session is closed".to_string(),
))
} else {
Ok(())
}
}
}
#[async_trait]
impl WorkdirSession for ReadOnlyWorkdirSession {
fn workdir(&self) -> &Workdir {
self.source.workdir()
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
WorkdirSessionCapabilities::READ_ONLY
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.ensure_open()?;
self.source.stat(request).await
}
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
self.ensure_open()?;
self.source.read(request).await
}
async fn write(&self, _request: WriteRequest) -> Result<WriteResult, WorkdirError> {
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Write))
}
async fn edit(&self, _request: EditRequest) -> Result<EditResult, WorkdirError> {
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Edit))
}
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
self.ensure_open()?;
self.source.list(request).await
}
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
self.ensure_open()?;
self.source.glob(request).await
}
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
self.ensure_open()?;
self.source.grep(request).await
}
async fn start_command(&self, _request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Command))
}
async fn command_status(&self, _handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Command))
}
async fn command_output(
&self,
_request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> {
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Command))
}
async fn cancel_command(&self, _handle: CommandHandle) -> Result<(), WorkdirError> {
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Command))
}
async fn close(&self) -> Result<(), WorkdirError> {
self.closed.store(true, Ordering::Release);
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum WorkdirError {
#[error("Workdir session does not support {0:?}")]
+2
View File
@@ -28,6 +28,7 @@ base64.workspace = true
axum = { workspace = true, optional = true }
futures = { workspace = true, optional = true }
decodal.workspace = true
flow = { path = "../flow" }
manifest.workspace = true
protocol.workspace = true
serde = { workspace = true, features = ["derive"] }
@@ -40,6 +41,7 @@ tar.workspace = true
thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
toml.workspace = true
uuid = { workspace = true, features = ["v7"] }
tower = { workspace = true, features = ["util"], optional = true }
worker.workspace = true
workdir.workspace = true
+12
View File
@@ -4,6 +4,10 @@ use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
fn is_false(value: &bool) -> bool {
!*value
}
/// Profile selector boundary. This is a selector, not a resolved runtime config.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
@@ -217,6 +221,14 @@ pub struct CreateWorkerRequest {
pub working_directory_request: Option<WorkingDirectoryRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryClaim>,
/// Backend-only feature enablement. Grants still define local Runtime peers;
/// the Workspace provider reauthorizes its dynamic set per operation.
#[serde(default, skip_serializing_if = "is_false")]
pub worker_observation_enabled: bool,
/// Backend-authored, bounded peer session grants. Runtime revalidates each
/// requested capture against this exact canonical `(runtime_id, worker_id)` set.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub worker_observation_grants: Vec<RuntimeWorkerRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_api: Option<WorkspaceApiRef>,
}
+54
View File
@@ -36,6 +36,16 @@ pub enum WorkerExecutionOperation {
Cancel,
}
/// Evidence that a user input reached the durable Worker session boundary.
///
/// This is intentionally distinct from accepting a method on the Worker's
/// in-memory channel. For Flow submissions, the committed UserInput entry also
/// carries the initial Flow runtime-state extension.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerInputCommitAck {
pub submission_id: String,
}
/// Typed execution result class. Results are transient operation outcomes and
/// are not persisted as Worker lifecycle authority.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -45,6 +55,8 @@ pub struct WorkerExecutionResult {
pub run_state: WorkerExecutionRunState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_commit: Option<WorkerInputCommitAck>,
}
/// Backend result class for a Worker execution operation.
@@ -68,6 +80,23 @@ impl WorkerExecutionResult {
outcome: WorkerExecutionOutcome::Accepted,
run_state,
message: None,
input_commit: None,
}
}
pub fn accepted_input_committed(
operation: WorkerExecutionOperation,
run_state: WorkerExecutionRunState,
submission_id: impl Into<String>,
) -> Self {
Self {
operation,
outcome: WorkerExecutionOutcome::Accepted,
run_state,
message: None,
input_commit: Some(WorkerInputCommitAck {
submission_id: submission_id.into(),
}),
}
}
@@ -77,6 +106,7 @@ impl WorkerExecutionResult {
outcome: WorkerExecutionOutcome::Busy,
run_state: WorkerExecutionRunState::Busy,
message: Some(message.into()),
input_commit: None,
}
}
@@ -86,6 +116,7 @@ impl WorkerExecutionResult {
outcome: WorkerExecutionOutcome::Rejected,
run_state: WorkerExecutionRunState::Stopped,
message: Some(message.into()),
input_commit: None,
}
}
@@ -95,6 +126,7 @@ impl WorkerExecutionResult {
outcome: WorkerExecutionOutcome::Errored,
run_state: WorkerExecutionRunState::Errored,
message: Some(message.into()),
input_commit: None,
}
}
@@ -104,6 +136,7 @@ impl WorkerExecutionResult {
outcome: WorkerExecutionOutcome::Unsupported,
run_state: WorkerExecutionRunState::Stopped,
message: Some(message.into()),
input_commit: None,
}
}
@@ -472,6 +505,27 @@ impl WorkerExecutionBackendRef {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_commit_ack_survives_json_round_trip() {
let result = WorkerExecutionResult::accepted_input_committed(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
"submission-1",
);
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"submission_id\":\"submission-1\""));
assert_eq!(
serde_json::from_str::<WorkerExecutionResult>(&json).unwrap(),
result
);
}
}
impl fmt::Debug for WorkerExecutionBackendRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WorkerExecutionBackendRef")
+30 -10
View File
@@ -2061,6 +2061,8 @@ mod tests {
initial_input: None,
working_directory_request: None,
working_directory: None,
worker_observation_enabled: false,
worker_observation_grants: Vec::new(),
workspace_api: None,
}
}
@@ -2206,12 +2208,20 @@ mod tests {
fn dispatch_input(
&self,
_handle: &WorkerExecutionHandle,
_input: WorkerInput,
input: WorkerInput,
) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
)
if let Some(submission_id) = input.submission_id {
WorkerExecutionResult::accepted_input_committed(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
submission_id,
)
} else {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
)
}
}
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
@@ -2513,12 +2523,20 @@ mod ws_tests {
fn dispatch_input(
&self,
_handle: &WorkerExecutionHandle,
_input: WorkerInput,
input: WorkerInput,
) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
)
if let Some(submission_id) = input.submission_id {
WorkerExecutionResult::accepted_input_committed(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
submission_id,
)
} else {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
)
}
}
fn dispatch_method(
@@ -2588,6 +2606,8 @@ mod ws_tests {
initial_input: None,
working_directory_request: None,
working_directory: None,
worker_observation_enabled: false,
worker_observation_grants: Vec::new(),
workspace_api: None,
}
}
+6
View File
@@ -25,6 +25,10 @@ impl WorkerInputKind {
pub struct WorkerInput {
pub kind: WorkerInputKind,
pub content: String,
/// Runtime-generated correlation id. This is never accepted from public
/// JSON input and is consumed only by the execution backend.
#[serde(skip)]
pub submission_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segments: Option<Vec<Segment>>,
}
@@ -34,6 +38,7 @@ impl WorkerInput {
Self {
kind: WorkerInputKind::User,
content: content.into(),
submission_id: None,
segments: None,
}
}
@@ -42,6 +47,7 @@ impl WorkerInput {
Self {
kind: WorkerInputKind::Notify,
content: content.into(),
submission_id: None,
segments: None,
}
}
+270 -15
View File
@@ -40,6 +40,7 @@ use std::sync::{Arc, Mutex, MutexGuard, Weak};
#[cfg(feature = "ws-server")]
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use uuid::Uuid;
/// Workspace-scoped Runtime authorization context supplied by a trusted backend.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -565,10 +566,12 @@ impl Runtime {
}
};
if let Some(initial_input) = {
if let Some(mut initial_input) = {
let state = self.lock()?;
state.worker(&worker_ref)?.request.initial_input.clone()
} {
let expected_submission_id = Uuid::now_v7().to_string();
initial_input.submission_id = Some(expected_submission_id.clone());
let dispatch_result = backend.dispatch_input(&handle, initial_input.clone());
if !dispatch_result.is_accepted() {
let _ = backend.stop_worker(&handle);
@@ -581,15 +584,32 @@ impl Runtime {
result: dispatch_result,
});
}
let has_commit_ack = dispatch_result
.input_commit
.as_ref()
.is_some_and(|ack| ack.submission_id == expected_submission_id);
if !has_commit_ack {
let _ = backend.stop_worker(&handle);
self.rollback_failed_create(&worker_ref)?;
let result = WorkerExecutionResult::rejected(
WorkerExecutionOperation::Input,
"execution backend accepted initial input without a durable session commit acknowledgement",
);
return Err(RuntimeError::WorkerExecutionRejected {
worker_id: worker_ref.worker_id.clone(),
operation: result.operation,
outcome: result.outcome,
message: result.message_or_default(),
result,
});
}
let initial_run_state = dispatch_result.run_state;
let detail = self.commit_created_worker(
&worker_ref,
handle,
WorkerExecutionRunState::Busy,
initial_run_state,
working_directory,
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
),
dispatch_result,
)?;
self.record_input_observation(&worker_ref, initial_input)?;
Ok(detail)
@@ -936,9 +956,16 @@ impl Runtime {
pub fn send_input(
&self,
worker_ref: &WorkerRef,
input: WorkerInput,
mut input: WorkerInput,
) -> Result<WorkerInteractionAck, RuntimeError> {
validate_worker_input(&input)?;
let expected_submission_id = if input.kind == WorkerInputKind::User {
let submission_id = Uuid::now_v7().to_string();
input.submission_id = Some(submission_id.clone());
Some(submission_id)
} else {
None
};
self.ensure_worker_execution(worker_ref)?;
let (backend, handle) = {
let state = self.lock()?;
@@ -975,6 +1002,25 @@ impl Runtime {
result: dispatch_result,
});
}
if let Some(expected_submission_id) = expected_submission_id
&& dispatch_result
.input_commit
.as_ref()
.is_none_or(|ack| ack.submission_id != expected_submission_id)
{
let result = WorkerExecutionResult::rejected(
WorkerExecutionOperation::Input,
"execution backend did not acknowledge the committed Runtime submission id",
);
self.record_execution_result(worker_ref, result.clone())?;
return Err(RuntimeError::WorkerExecutionRejected {
worker_id: worker_ref.worker_id.clone(),
operation: result.operation,
outcome: result.outcome,
message: result.message_or_default(),
result,
});
}
let mut state = self.lock()?;
state.ensure_running()?;
@@ -2333,7 +2379,11 @@ fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), R
kind: format!("{:?}", input.kind),
});
}
if input.content.trim().is_empty() {
let has_segments = input
.segments
.as_ref()
.is_some_and(|segments| !segments.is_empty());
if input.content.trim().is_empty() && !has_segments {
return Err(RuntimeError::InvalidRequest(
"initial_input.content must not be empty".to_string(),
));
@@ -2369,7 +2419,11 @@ fn validate_create_workspace_scope(
}
fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> {
if !input.kind.is_empty_content_allowed() && input.content.trim().is_empty() {
let has_segments = input
.segments
.as_ref()
.is_some_and(|segments| !segments.is_empty());
if !input.kind.is_empty_content_allowed() && input.content.trim().is_empty() && !has_segments {
return Err(RuntimeError::InvalidRequest(
"worker input content must not be empty".to_string(),
));
@@ -2447,6 +2501,47 @@ mod tests {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
#[test]
fn typed_segments_allow_empty_flat_content() {
let input = WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
submission_id: None,
segments: Some(vec![protocol::Segment::Flow {
selector: "builtin:coder-review".to_string(),
}]),
};
assert!(validate_worker_input(&input).is_ok());
}
#[test]
fn empty_user_input_without_segments_is_rejected() {
let input = WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
submission_id: None,
segments: Some(Vec::new()),
};
assert!(matches!(
validate_worker_input(&input),
Err(RuntimeError::InvalidRequest(_))
));
}
#[test]
fn typed_flow_segments_allow_empty_initial_flat_content() {
let mut request = task_request("flow");
request.initial_input = Some(WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
submission_id: None,
segments: Some(vec![protocol::Segment::Flow {
selector: "builtin:coder-review".to_string(),
}]),
});
assert!(validate_create_worker_request(&request).is_ok());
}
fn task_request(_objective: &str) -> CreateWorkerRequest {
let profile = ProfileSelector::Builtin("builtin:coder".to_string());
let bundle = test_bundle_for_profile(profile.clone());
@@ -2479,6 +2574,8 @@ mod tests {
initial_input: None,
working_directory_request: None,
working_directory: None,
worker_observation_enabled: false,
worker_observation_grants: Vec::new(),
workspace_api: None,
}
}
@@ -2531,6 +2628,8 @@ mod tests {
restore_result: Mutex<Option<WorkerExecutionSpawnResult>>,
restore_count: Mutex<u64>,
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
dispatched_inputs: Mutex<Vec<WorkerInput>>,
preserve_commit_ack_submission_id: AtomicBool,
#[cfg(feature = "ws-server")]
snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>,
}
@@ -2540,6 +2639,11 @@ mod tests {
*self.dispatch_result.lock().unwrap() = Some(result);
}
fn preserve_commit_ack_submission_id(&self) {
self.preserve_commit_ack_submission_id
.store(true, Ordering::SeqCst);
}
#[cfg(feature = "ws-server")]
fn set_worker_snapshot(&self, worker_ref: &WorkerRef, snapshot: protocol::Event) {
self.snapshots
@@ -2605,18 +2709,31 @@ mod tests {
fn dispatch_input(
&self,
_handle: &WorkerExecutionHandle,
_input: WorkerInput,
input: WorkerInput,
) -> WorkerExecutionResult {
self.dispatch_result
let submission_id = input.submission_id.clone();
self.dispatched_inputs.lock().unwrap().push(input);
let mut result = self
.dispatch_result
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| {
WorkerExecutionResult::accepted(
WorkerExecutionResult::accepted_input_committed(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
"test-submission",
)
})
});
if !self
.preserve_commit_ack_submission_id
.load(Ordering::SeqCst)
&& let (Some(ack), Some(submission_id)) =
(result.input_commit.as_mut(), submission_id)
{
ack.submission_id = submission_id;
}
result
}
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
@@ -3162,6 +3279,68 @@ mod tests {
assert!(runtime.list_workers().unwrap().is_empty());
}
#[test]
fn create_worker_uses_committed_input_ack_run_state() {
let (runtime, backend) = runtime_and_backend();
backend.set_dispatch_result(WorkerExecutionResult::accepted_input_committed(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
"test-submission",
));
let mut request = task_request("committed initial input is already idle");
request.initial_input = Some(WorkerInput::user("start the ticket"));
let detail = runtime.create_worker(request).unwrap();
assert_eq!(detail.status, WorkerStatus::Idle);
}
#[test]
fn create_worker_rejects_mismatched_input_commit_acknowledgement() {
let (runtime, backend) = runtime_and_backend();
backend.preserve_commit_ack_submission_id();
backend.set_dispatch_result(WorkerExecutionResult::accepted_input_committed(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
"forged-submission",
));
let mut request = task_request("mismatched initial input commit ack");
request.initial_input = Some(WorkerInput::user("start the ticket"));
let error = runtime.create_worker(request).unwrap_err();
assert!(matches!(
error,
RuntimeError::WorkerExecutionRejected {
outcome: crate::execution::WorkerExecutionOutcome::Rejected,
..
}
));
assert!(runtime.list_workers().unwrap().is_empty());
}
#[test]
fn create_worker_rejects_initial_input_without_commit_acknowledgement() {
let (runtime, backend) = runtime_and_backend();
backend.set_dispatch_result(WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
));
let mut request = task_request("missing initial input commit ack");
request.initial_input = Some(WorkerInput::user("start the ticket"));
let error = runtime.create_worker(request).unwrap_err();
assert!(matches!(
error,
RuntimeError::WorkerExecutionRejected {
outcome: crate::execution::WorkerExecutionOutcome::Rejected,
..
}
));
assert!(runtime.list_workers().unwrap().is_empty());
}
#[test]
fn create_worker_without_execution_backend_is_rejected_and_not_persisted() {
let runtime = Runtime::new_memory();
@@ -3306,11 +3485,12 @@ mod tests {
fn dispatch_input(
&self,
_handle: &WorkerExecutionHandle,
_input: WorkerInput,
input: WorkerInput,
) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionResult::accepted_input_committed(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
input.submission_id.expect("Runtime submission id"),
)
}
}
@@ -3377,6 +3557,81 @@ mod tests {
);
}
#[test]
fn restore_does_not_redispatch_spawn_initial_submit() {
let backend = Arc::new(TestExecutionBackend::default());
let runtime = Runtime::with_execution_backend(
RuntimeOptions {
..RuntimeOptions::default()
},
backend.clone(),
)
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let mut request = task_request("flow restore");
request.initial_input = Some(WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
submission_id: None,
segments: Some(vec![
protocol::Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
protocol::Segment::text("Implement Ticket 00001"),
]),
});
let detail = runtime.create_worker(request).unwrap();
assert_eq!(backend.dispatched_inputs.lock().unwrap().len(), 1);
runtime
.stop_worker(&detail.worker_ref, Some("restore test".to_string()))
.unwrap();
runtime.restore_worker(&detail.worker_ref).unwrap();
assert_eq!(
backend.dispatched_inputs.lock().unwrap().len(),
1,
"restore must continue durable Worker state without replaying spawn initial input"
);
}
#[test]
fn send_input_dispatches_segment_only_flow_submission() {
let backend = Arc::new(TestExecutionBackend::default());
let runtime = Runtime::with_execution_backend(
RuntimeOptions {
..RuntimeOptions::default()
},
backend.clone(),
)
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let detail = runtime.create_worker(task_request("flow segment")).unwrap();
let input = WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
submission_id: None,
segments: Some(vec![protocol::Segment::Flow {
selector: "builtin:coder-review".to_string(),
}]),
};
runtime
.send_input(&detail.worker_ref, input.clone())
.unwrap();
let dispatched = backend.dispatched_inputs.lock().unwrap();
assert_eq!(dispatched.len(), 1);
assert_eq!(dispatched[0].kind, input.kind);
assert_eq!(dispatched[0].content, input.content);
assert_eq!(dispatched[0].segments, input.segments);
let submission_id = dispatched[0]
.submission_id
.as_deref()
.expect("Runtime submission id");
Uuid::parse_str(submission_id).expect("submission id UUID");
}
#[cfg(feature = "ws-server")]
#[test]
fn send_input_records_protocol_observations() {
+634 -53
View File
@@ -31,25 +31,44 @@ use crate::working_directory::{
};
use async_trait::async_trait;
use manifest::paths;
use protocol::{Method, Segment, WorkerStatus};
use session_store::FsStore;
use session_store::{CombinedStore, FsWorkerStore};
use protocol::{Event, Method, Segment, WorkerStatus};
use session_store::{CombinedStore, FsStore, FsWorkerStore, LogEntry, collect_state};
use tokio::runtime::Runtime;
#[cfg(feature = "ws-server")]
use tokio::sync::broadcast;
use workdir::{LocalWorkdirSession, Workdir, WorkdirSessionCapabilities, WorkdirSessionHandle};
use worker::feature::builtin::{
CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationProvider,
WorkerObservationSubject, WorkerObservationSubjectRef, WorkerSessionCapture,
WorkspaceClientWorkerObservationProvider,
};
#[cfg(feature = "ws-server")]
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
use worker::{
PromptLoader, RuntimeWorkspaceHttpClient, Worker, WorkerController, WorkerError,
WorkerFilesystemAuthority, WorkerHandle, WorkerWorkspaceContext, WorkspaceId,
PromptLoader, RuntimeWorkspaceHttpClient, SegmentLogSink,
WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerController, WorkerError,
WorkerFilesystemAuthority, WorkerHandle, WorkerSharedState, WorkerWorkspaceContext,
WorkspaceClient, WorkspaceId,
};
const DEFAULT_BACKEND_ID: &str = "worker-crate";
const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
// Keep this below the adapter task timeout so a failed acknowledgement task
// returns a typed execution error instead of leaving the outer waiter to time out.
const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
static NEXT_RUNTIME_ARTIFACT_ROOT: AtomicU64 = AtomicU64::new(1);
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
let LogEntry::UserInput { extensions, .. } = entry else {
return false;
};
extensions.iter().any(|extension| {
extension.domain == WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN
&& extension.payload["submission_id"].as_str() == Some(submission_id)
})
}
#[derive(Clone)]
enum RuntimeArtifactRoot {
Owned(Arc<OwnedRuntimeArtifactRoot>),
@@ -85,6 +104,11 @@ impl Drop for OwnedRuntimeArtifactRoot {
}
}
pub struct RuntimeWorkerController {
pub handle: WorkerHandle,
pub workspace_client: Arc<dyn WorkspaceClient>,
}
/// Factory seam used by [`WorkerRuntimeExecutionBackend`] to construct a real
/// controller-backed Worker for a Runtime catalog entry.
#[async_trait]
@@ -92,18 +116,133 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
async fn spawn_controller(
&self,
request: WorkerExecutionSpawnRequest,
) -> Result<WorkerHandle, String>;
) -> Result<RuntimeWorkerController, String>;
async fn restore_controller(
&self,
request: WorkerExecutionRestoreRequest,
) -> Result<WorkerHandle, String>;
) -> Result<RuntimeWorkerController, String>;
}
/// Production factory that resolves a normal Worker profile and spawns it under
/// `WorkerController`.
#[derive(Default)]
struct RuntimeWorkerObservationHub {
workers: Mutex<HashMap<WorkerRef, RuntimeObservedWorker>>,
}
#[derive(Clone)]
struct RuntimeObservedWorker {
workspace_id: Option<String>,
shared_state: std::sync::Weak<WorkerSharedState>,
sink: SegmentLogSink,
}
impl RuntimeWorkerObservationHub {
fn register(&self, worker_ref: WorkerRef, workspace_id: Option<String>, handle: &WorkerHandle) {
if let Ok(mut workers) = self.workers.lock() {
workers.insert(
worker_ref,
RuntimeObservedWorker {
workspace_id,
shared_state: Arc::downgrade(&handle.shared_state),
sink: handle.sink.clone(),
},
);
}
}
fn get(
&self,
worker_ref: &WorkerRef,
) -> Option<(Option<String>, Arc<WorkerSharedState>, SegmentLogSink)> {
let mut workers = self.workers.lock().ok()?;
let entry = workers.get(worker_ref)?.clone();
let Some(shared_state) = entry.shared_state.upgrade() else {
workers.remove(worker_ref);
return None;
};
Some((entry.workspace_id, shared_state, entry.sink))
}
}
struct RuntimeGrantedWorkerObservationProvider {
runtime_id: String,
workspace_id: String,
grants: std::collections::HashSet<crate::identity::RuntimeWorkerRef>,
hub: Arc<RuntimeWorkerObservationHub>,
}
#[async_trait]
impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider {
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
let mut subjects = Vec::new();
for grant in &self.grants {
if grant.runtime_id != self.runtime_id {
continue;
}
let Ok(worker_ref) = grant.local_worker_ref() else {
continue;
};
let Some((workspace_id, state, _)) = self.hub.get(&worker_ref) else {
continue;
};
if workspace_id.as_deref() != Some(self.workspace_id.as_str()) {
continue;
}
subjects.push(WorkerObservationSubject {
subject: WorkerObservationSubjectRef::RuntimeWorker {
runtime_id: grant.runtime_id.clone(),
worker_id: grant.worker_id.clone(),
},
display_name: grant.worker_id.clone(),
relation: "granted_peer".to_string(),
status: format!("{:?}", state.get_status()).to_lowercase(),
});
}
subjects.sort_by(|left, right| left.subject.cmp(&right.subject));
Ok(subjects)
}
async fn capture_worker_session(
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError> {
let WorkerObservationSubjectRef::RuntimeWorker {
runtime_id,
worker_id,
} = subject
else {
return Err(WorkerObservationError::NotFound);
};
let grant = crate::identity::RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone());
if !self.grants.contains(&grant) || runtime_id != &self.runtime_id {
return Err(WorkerObservationError::NotFound);
}
let worker_ref = grant
.local_worker_ref()
.map_err(|_| WorkerObservationError::NotFound)?;
let (workspace_id, _, sink) = self
.hub
.get(&worker_ref)
.ok_or(WorkerObservationError::NotFound)?;
if workspace_id.as_deref() != Some(self.workspace_id.as_str()) {
return Err(WorkerObservationError::NotFound);
}
let entries = sink.subscribe_with_snapshot().0;
let state = collect_state(&entries);
Ok(WorkerSessionCapture {
segment_id: format!("runtime:{runtime_id}:worker:{worker_id}"),
items: state.history,
})
}
}
#[derive(Clone)]
pub struct ProfileRuntimeWorkerFactory {
observation_hub: Arc<RuntimeWorkerObservationHub>,
profile_base_dir: PathBuf,
store_dir: Option<PathBuf>,
worker_metadata_dir: Option<PathBuf>,
@@ -116,6 +255,7 @@ impl ProfileRuntimeWorkerFactory {
pub fn new(profile_base_dir: impl Into<PathBuf>) -> Self {
let profile_base_dir = profile_base_dir.into();
Self {
observation_hub: Arc::new(RuntimeWorkerObservationHub::default()),
profile_base_dir,
store_dir: None,
worker_metadata_dir: None,
@@ -377,7 +517,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
async fn spawn_controller(
&self,
request: WorkerExecutionSpawnRequest,
) -> Result<WorkerHandle, String> {
) -> Result<RuntimeWorkerController, String> {
let worker_name = Self::runtime_worker_name(&request);
let profile = Self::runtime_profile(&request);
let has_local_filesystem = request.working_directory.is_some();
@@ -398,6 +538,18 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
.unwrap_or(WorkerFilesystemAuthority::None);
let workspace_backend_ref =
RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
let observation_runtime_id = request
.request
.workspace_api
.as_ref()
.and_then(|api| api.runtime_id.clone());
let observation_workspace_id = request
.request
.workspace_api
.as_ref()
.map(|api| api.workspace_id.clone());
let observation_grants = request.request.worker_observation_grants.clone();
let observation_enabled = request.request.worker_observation_enabled;
let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref);
let selector = profile.as_ref();
let archive = self
@@ -421,6 +573,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
)?
}
};
let flow_transition_enabled = manifest.feature.flow.enabled;
let store_dir = self.store_dir()?;
let session_store = FsStore::new(&store_dir).map_err(|err| {
@@ -457,18 +610,60 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
} else {
worker.bind_workdir_session(None);
}
if let (Some(runtime_id), Some(workspace_id)) =
(observation_runtime_id, observation_workspace_id.clone())
&& observation_enabled
{
let mut providers: Vec<Arc<dyn WorkerObservationProvider>> = vec![Arc::new(
WorkspaceClientWorkerObservationProvider::new(worker.workspace_client_handle()),
)];
if !observation_grants.is_empty() {
providers.push(Arc::new(RuntimeGrantedWorkerObservationProvider {
runtime_id,
workspace_id,
grants: observation_grants.into_iter().take(100).collect(),
hub: self.observation_hub.clone(),
}));
}
worker.bind_worker_observation_provider(Some(Arc::new(
CompositeWorkerObservationProvider::new(providers),
)));
}
if flow_transition_enabled {
let report = worker
.install_runtime_flow_transition_feature()
.map_err(|error| format!("install Flow transition feature: {error}"))?;
if report.reports.iter().any(|report| !report.installed) {
return Err(format!(
"install Flow transition feature failed: {:?}",
report.reports
));
}
}
let workspace_client = worker.workspace_client_handle();
let runtime_base = self.runtime_base_dir()?;
let (handle, _shutdown_rx) = WorkerController::spawn_runtime_managed(worker, &runtime_base)
.await
.map_err(|err| format!("failed to spawn Worker controller: {err}"))?;
Ok(handle)
if flow_transition_enabled {
handle.shared_state.enable_flow_transition();
}
self.observation_hub.register(
request.worker_ref.clone(),
observation_workspace_id,
&handle,
);
Ok(RuntimeWorkerController {
handle,
workspace_client,
})
}
async fn restore_controller(
&self,
request: WorkerExecutionRestoreRequest,
) -> Result<WorkerHandle, String> {
) -> Result<RuntimeWorkerController, String> {
let worker_name = Self::runtime_worker_name_for_ref(&request.worker_ref);
let filesystem_authority = request
.working_directory
@@ -482,6 +677,18 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
.unwrap_or(WorkerFilesystemAuthority::None);
let workspace_backend_ref =
RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
let observation_runtime_id = request
.request
.workspace_api
.as_ref()
.and_then(|api| api.runtime_id.clone());
let observation_workspace_id = request
.request
.workspace_api
.as_ref()
.map(|api| api.workspace_id.clone());
let observation_grants = request.request.worker_observation_grants.clone();
let observation_enabled = request.request.worker_observation_enabled;
let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref);
let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?;
@@ -542,6 +749,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
}
Err(err) => return Err(format!("failed to restore Worker from metadata: {err}")),
};
let flow_transition_enabled = worker.manifest().feature.flow.enabled;
if let Some(binding) = request.working_directory.as_ref() {
worker.bind_workdir_session(Some(runtime_local_workdir_session(
&binding.working_directory.id,
@@ -552,18 +760,61 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
} else {
worker.bind_workdir_session(None);
}
if let (Some(runtime_id), Some(workspace_id)) =
(observation_runtime_id, observation_workspace_id.clone())
&& observation_enabled
{
let mut providers: Vec<Arc<dyn WorkerObservationProvider>> = vec![Arc::new(
WorkspaceClientWorkerObservationProvider::new(worker.workspace_client_handle()),
)];
if !observation_grants.is_empty() {
providers.push(Arc::new(RuntimeGrantedWorkerObservationProvider {
runtime_id,
workspace_id,
grants: observation_grants.into_iter().take(100).collect(),
hub: self.observation_hub.clone(),
}));
}
worker.bind_worker_observation_provider(Some(Arc::new(
CompositeWorkerObservationProvider::new(providers),
)));
}
if flow_transition_enabled {
let report = worker
.install_runtime_flow_transition_feature()
.map_err(|error| format!("install Flow transition feature: {error}"))?;
if report.reports.iter().any(|report| !report.installed) {
return Err(format!(
"install Flow transition feature failed: {:?}",
report.reports
));
}
}
let workspace_client = worker.workspace_client_handle();
let runtime_base = self.runtime_base_dir()?;
let (handle, _shutdown_rx) = WorkerController::spawn_runtime_managed(worker, &runtime_base)
.await
.map_err(|err| format!("failed to spawn restored Worker controller: {err}"))?;
Ok(handle)
if flow_transition_enabled {
handle.shared_state.enable_flow_transition();
}
self.observation_hub.register(
request.worker_ref.clone(),
observation_workspace_id,
&handle,
);
Ok(RuntimeWorkerController {
handle,
workspace_client,
})
}
}
struct RuntimeWorkerExecution {
handle: WorkerHandle,
busy: Arc<AtomicBool>,
workspace_client: Option<Arc<dyn WorkspaceClient>>,
}
/// `worker-runtime` execution backend backed by real `worker` crate Workers.
@@ -654,7 +905,14 @@ where
fn get_execution(
&self,
handle: &WorkerExecutionHandle,
) -> Result<(WorkerHandle, Arc<AtomicBool>), WorkerExecutionResult> {
) -> Result<
(
WorkerHandle,
Arc<AtomicBool>,
Option<Arc<dyn WorkspaceClient>>,
),
WorkerExecutionResult,
> {
if handle.backend_id() != self.backend_id() {
return Err(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Input,
@@ -673,7 +931,13 @@ where
})?;
workers
.get(handle.worker_ref())
.map(|execution| (execution.handle.clone(), execution.busy.clone()))
.map(|execution| {
(
execution.handle.clone(),
execution.busy.clone(),
execution.workspace_client.clone(),
)
})
.ok_or_else(|| {
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Input,
@@ -699,6 +963,131 @@ where
.unwrap_or_else(|message| WorkerExecutionResult::errored(operation, message))
}
fn send_user_input_and_wait_for_commit(
&self,
operation: WorkerExecutionOperation,
worker: WorkerHandle,
method: Method,
submission_id: String,
accepted_run_state: WorkerExecutionRunState,
) -> WorkerExecutionResult {
let acknowledged_submission_id = submission_id.clone();
self.run_on_adapter_runtime(async move {
// Subscribe before enqueueing the input so the acknowledgement cannot
// race with a fast Worker commit. The opaque submission id is stored in
// the same UserInput entry as the transformed Flow input and its state.
let (_, mut committed_entries) = worker.sink.subscribe_with_snapshot();
let committed_probe = worker.clone();
let mut events = worker.subscribe();
worker
.send(method)
.await
.map_err(|err| format!("failed to send Worker method: {err}"))?;
let timeout_probe = committed_probe.clone();
let timeout_submission_id = submission_id.clone();
let acknowledgement = tokio::time::timeout(USER_INPUT_COMMIT_TIMEOUT, async move {
let input_was_committed = || {
committed_probe
.committed_entries()
.iter()
.any(|entry| user_input_has_submission(entry, &submission_id))
};
loop {
tokio::select! {
entry = committed_entries.recv() => {
match entry {
Ok(entry) if user_input_has_submission(&entry, &submission_id) => {
return Ok(());
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
if input_was_committed() {
return Ok(());
}
return Err(format!(
"worker input commit acknowledgement lagged by {skipped} entry event(s)"
));
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
if input_was_committed() {
return Ok(());
}
return Err(
"worker entry stream closed before user input was committed"
.to_string(),
);
}
}
}
event = events.recv() => {
match event {
Ok(Event::Error { message, .. }) => {
if input_was_committed() {
return Ok(());
}
return Err(format!(
"worker rejected user input before session commit: {message}"
));
}
Ok(Event::Shutdown) => {
if input_was_committed() {
return Ok(());
}
return Err(
"worker shut down before user input was committed".to_string()
);
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
if input_was_committed() {
return Ok(());
}
return Err(format!(
"worker input commit acknowledgement lagged by {skipped} protocol event(s)"
));
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
if input_was_committed() {
return Ok(());
}
return Err(
"worker event stream closed before user input was committed"
.to_string(),
);
}
}
}
}
}
})
.await;
match acknowledgement {
Ok(result) => result,
Err(_) => {
if timeout_probe
.committed_entries()
.iter()
.any(|entry| user_input_has_submission(entry, &timeout_submission_id))
{
Ok(())
} else {
Err("timed out waiting for worker user input commit".to_string())
}
}
}
})
.map(|_| {
WorkerExecutionResult::accepted_input_committed(
operation,
accepted_run_state,
acknowledged_submission_id,
)
})
.unwrap_or_else(|message| WorkerExecutionResult::errored(operation, message))
}
fn connect_handle(
&self,
operation: WorkerExecutionOperation,
@@ -706,6 +1095,7 @@ where
bridge_context: crate::execution::WorkerExecutionContext,
handle: WorkerHandle,
working_directory: Option<WorkingDirectoryBinding>,
workspace_client: Option<Arc<dyn WorkspaceClient>>,
) -> WorkerExecutionSpawnResult {
let busy = Arc::new(AtomicBool::new(false));
#[cfg(feature = "ws-server")]
@@ -763,7 +1153,14 @@ where
));
}
};
workers.insert(worker_ref.clone(), RuntimeWorkerExecution { handle, busy });
workers.insert(
worker_ref.clone(),
RuntimeWorkerExecution {
handle,
busy,
workspace_client,
},
);
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
@@ -787,6 +1184,7 @@ fn method_starts_turn(method: &Method) -> bool {
matches!(
method,
Method::Run { .. }
| Method::RunTracked { .. }
| Method::Notify { auto_run: true, .. }
| Method::Resume
| Method::Compact
@@ -804,6 +1202,7 @@ fn accepted_notify_run_state(status: WorkerStatus, auto_run: bool) -> WorkerExec
fn accepted_run_state_for_method(method: &Method) -> WorkerExecutionRunState {
match method {
Method::Run { .. }
| Method::RunTracked { .. }
| Method::Notify { auto_run: true, .. }
| Method::Resume
| Method::Compact => WorkerExecutionRunState::Busy,
@@ -973,8 +1372,8 @@ where
let spawn_result =
self.run_on_adapter_runtime(async move { factory.spawn_controller(request).await });
let handle = match spawn_result {
Ok(handle) => handle,
let controller = match spawn_result {
Ok(controller) => controller,
Err(message) => {
if let (Some(materializer), Some(binding)) = (
self.working_directory_materializer.as_ref(),
@@ -993,8 +1392,9 @@ where
WorkerExecutionOperation::Spawn,
worker_ref,
bridge_context,
handle,
controller.handle,
working_directory,
Some(controller.workspace_client),
)
}
@@ -1074,8 +1474,8 @@ where
let restore_result =
self.run_on_adapter_runtime(async move { factory.restore_controller(request).await });
let handle = match restore_result {
Ok(handle) => handle,
let controller = match restore_result {
Ok(controller) => controller,
Err(message) => {
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
WorkerExecutionOperation::Restore,
@@ -1088,8 +1488,9 @@ where
WorkerExecutionOperation::Restore,
worker_ref,
bridge_context,
handle,
controller.handle,
working_directory,
Some(controller.workspace_client),
)
}
@@ -1098,7 +1499,7 @@ where
handle: &WorkerExecutionHandle,
input: WorkerInput,
) -> WorkerExecutionResult {
let (worker, busy) = match self.get_execution(handle) {
let (worker, busy, _workspace_client) = match self.get_execution(handle) {
Ok(execution) => execution,
Err(mut result) => {
result.operation = WorkerExecutionOperation::Input;
@@ -1140,35 +1541,66 @@ where
);
}
let method = match input.kind {
WorkerInputKind::User => Method::Run {
input: input
.segments
.unwrap_or_else(|| vec![Segment::text(input.content.trim().to_string())]),
},
let (method, submission_id) = match input.kind {
WorkerInputKind::User => {
let Some(submission_id) = input
.submission_id
.filter(|submission_id| !submission_id.trim().is_empty())
else {
busy.store(false, Ordering::SeqCst);
return WorkerExecutionResult::rejected(
WorkerExecutionOperation::Input,
"Runtime user input is missing its internal submission id",
);
};
(
Method::RunTracked {
input: input.segments.unwrap_or_else(|| {
vec![Segment::text(input.content.trim().to_string())]
}),
submission_id: submission_id.clone(),
},
Some(submission_id),
)
}
WorkerInputKind::Notify => {
unreachable!("Notify input is dispatched before the turn-start busy guard")
}
WorkerInputKind::Compact => Method::Compact,
WorkerInputKind::ListRewindTargets => Method::ListRewindTargets,
WorkerInputKind::RegisterPeer => Method::RegisterPeer {
name: input.content.trim().to_string(),
},
WorkerInputKind::Compact => (Method::Compact, None),
WorkerInputKind::ListRewindTargets => (Method::ListRewindTargets, None),
WorkerInputKind::RegisterPeer => (
Method::RegisterPeer {
name: input.content.trim().to_string(),
},
None,
),
};
let accepted_run_state = match method {
Method::Run { .. } | Method::Notify { .. } | Method::Compact => {
WorkerExecutionRunState::Busy
}
Method::Run { .. }
| Method::RunTracked { .. }
| Method::Notify { .. }
| Method::Compact => WorkerExecutionRunState::Busy,
_ => WorkerExecutionRunState::Idle,
};
let accepted_is_idle = accepted_run_state == WorkerExecutionRunState::Idle;
let waits_for_user_input_commit = submission_id.is_some();
let result = self.send_method(
WorkerExecutionOperation::Input,
worker,
method,
accepted_run_state,
);
let result = if waits_for_user_input_commit {
self.send_user_input_and_wait_for_commit(
WorkerExecutionOperation::Input,
worker,
method,
submission_id.expect("tracked Run has submission id"),
accepted_run_state,
)
} else {
self.send_method(
WorkerExecutionOperation::Input,
worker,
method,
accepted_run_state,
)
};
if accepted_is_idle || result.outcome != crate::execution::WorkerExecutionOutcome::Accepted
{
busy.store(false, Ordering::SeqCst);
@@ -1181,7 +1613,7 @@ where
handle: &WorkerExecutionHandle,
method: Method,
) -> WorkerExecutionResult {
let (worker, busy) = match self.get_execution(handle) {
let (worker, busy, _workspace_client) = match self.get_execution(handle) {
Ok(execution) => execution,
Err(mut result) => {
result.operation = WorkerExecutionOperation::ProtocolMethod;
@@ -1275,7 +1707,7 @@ where
}
fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
let (worker, _busy) = match self.get_execution(handle) {
let (worker, _busy, _workspace_client) = match self.get_execution(handle) {
Ok(execution) => execution,
Err(mut result) => {
result.operation = WorkerExecutionOperation::Cancel;
@@ -1348,7 +1780,7 @@ mod tests {
use llm_engine::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_engine::llm_client::{ClientError, LlmClient, Request};
use manifest::{Scope, WorkerManifest};
use session_store::WorkerMetadataStore;
use session_store::{LogEntry, WorkerMetadataStore};
#[test]
fn notify_run_state_allows_running_worker_inbox_delivery() {
@@ -1433,7 +1865,7 @@ mod tests {
async fn spawn_controller(
&self,
request: WorkerExecutionSpawnRequest,
) -> Result<WorkerHandle, String> {
) -> Result<RuntimeWorkerController, String> {
let manifest = WorkerManifest::from_toml(
r#"
[worker]
@@ -1475,7 +1907,7 @@ mod tests {
let workspace_backend_ref =
RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref);
let workspace_client = workspace_context.client();
let workspace_client = workspace_context.client_handle();
self.observed_workspace_clients.lock().unwrap().push((
workspace_client.kind().to_string(),
workspace_client.workspace_id().map(str::to_string),
@@ -1496,12 +1928,15 @@ mod tests {
WorkerController::spawn_runtime_managed(worker, &self.runtime_base)
.await
.map_err(|err| err.to_string())?;
Ok(handle)
Ok(RuntimeWorkerController {
handle,
workspace_client,
})
}
async fn restore_controller(
&self,
request: WorkerExecutionRestoreRequest,
) -> Result<WorkerHandle, String> {
) -> Result<RuntimeWorkerController, String> {
let request = WorkerExecutionSpawnRequest {
worker_ref: request.worker_ref,
request: request.request,
@@ -1603,6 +2038,8 @@ mod tests {
initial_input: None,
working_directory_request: None,
working_directory: None,
worker_observation_enabled: false,
worker_observation_grants: Vec::new(),
workspace_api: None,
}
}
@@ -1625,14 +2062,14 @@ mod tests {
async fn spawn_controller(
&self,
_request: WorkerExecutionSpawnRequest,
) -> Result<WorkerHandle, String> {
) -> Result<RuntimeWorkerController, String> {
Err("spawn failed".to_string())
}
async fn restore_controller(
&self,
_request: WorkerExecutionRestoreRequest,
) -> Result<WorkerHandle, String> {
) -> Result<RuntimeWorkerController, String> {
Err("restore failed".to_string())
}
}
@@ -1688,6 +2125,86 @@ mod tests {
runtime_base.join(working_directory_id).join("checkout")
}
#[tokio::test]
async fn runtime_provider_projects_only_explicit_live_canonical_grants() {
let hub = Arc::new(RuntimeWorkerObservationHub::default());
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(7));
let shared_state = Arc::new(WorkerSharedState::new(
"peer-worker".to_string(),
session_store::new_segment_id(),
"[worker]\nname = \"peer-worker\"".to_string(),
protocol::Greeting {
worker_name: "peer-worker".to_string(),
cwd: "/tmp".to_string(),
provider: "test".to_string(),
model: "test".to_string(),
scope_summary: String::new(),
tools: Vec::new(),
context_window: 1_000,
context_tokens: 0,
},
));
hub.workers.lock().unwrap().insert(
worker_ref,
RuntimeObservedWorker {
workspace_id: Some("workspace-1".to_string()),
shared_state: Arc::downgrade(&shared_state),
sink: SegmentLogSink::new(),
},
);
let grant = crate::identity::RuntimeWorkerRef::new("runtime-1", "7");
let provider = RuntimeGrantedWorkerObservationProvider {
runtime_id: "runtime-1".to_string(),
workspace_id: "workspace-1".to_string(),
grants: std::collections::HashSet::from([grant.clone()]),
hub: hub.clone(),
};
let listed = provider.list_worker_sessions().await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(
listed[0].subject,
WorkerObservationSubjectRef::RuntimeWorker {
runtime_id: "runtime-1".to_string(),
worker_id: "7".to_string(),
}
);
provider
.capture_worker_session(&listed[0].subject)
.await
.expect("granted live peer should be capturable");
let hidden = provider
.capture_worker_session(&WorkerObservationSubjectRef::RuntimeWorker {
runtime_id: "runtime-1".to_string(),
worker_id: "8".to_string(),
})
.await
.unwrap_err();
assert!(matches!(hidden, WorkerObservationError::NotFound));
let cross_workspace = RuntimeGrantedWorkerObservationProvider {
runtime_id: "runtime-1".to_string(),
workspace_id: "workspace-2".to_string(),
grants: std::collections::HashSet::from([grant]),
hub: hub.clone(),
};
assert!(
cross_workspace
.list_worker_sessions()
.await
.unwrap()
.is_empty()
);
let hidden = cross_workspace
.capture_worker_session(&listed[0].subject)
.await
.unwrap_err();
assert!(matches!(hidden, WorkerObservationError::NotFound));
drop(shared_state);
assert!(provider.list_worker_sessions().await.unwrap().is_empty());
}
#[test]
fn runtime_worker_name_is_runtime_local() {
let worker_ref = crate::identity::WorkerRef::new(crate::identity::WorkerId::new(1));
@@ -1765,6 +2282,9 @@ mod tests {
[engine]
max_tokens = 100
[feature.flow]
enabled = true
[[scope.allow]]
target = "{}"
permission = "write"
@@ -1785,8 +2305,13 @@ mod tests {
)
.unwrap();
let request = create_request("restore");
let handle = ProfileRuntimeWorkerFactory::new(root.path())
let mut request = create_request("restore");
request.workspace_api = Some(crate::catalog::WorkspaceApiRef {
workspace_id: "workspace-restore".to_string(),
base_url: "http://workspace.invalid".to_string(),
runtime_id: Some("runtime-restore".to_string()),
});
let controller = ProfileRuntimeWorkerFactory::new(root.path())
.with_store_dir(&store_dir)
.with_worker_metadata_dir(&worker_metadata_dir)
.restore_controller(WorkerExecutionRestoreRequest {
@@ -1799,8 +2324,9 @@ mod tests {
})
.await
.expect("pending restore should use the saved manifest snapshot");
assert!(controller.handle.shared_state.flow_transition_enabled());
handle.send(Method::Shutdown).await.unwrap();
controller.handle.send(Method::Shutdown).await.unwrap();
}
#[test]
@@ -1821,6 +2347,61 @@ mod tests {
);
}
#[test]
fn create_with_initial_input_returns_after_session_commit() {
let client = MockClient::new(simple_text_events());
let runtime_base = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
let factory = MockFactory {
client,
runtime_base: runtime_base.path().to_path_buf(),
cwd: cwd.path().to_path_buf(),
store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"),
observed_cwds: Arc::new(Mutex::new(Vec::new())),
observed_workspace_clients: Arc::new(Mutex::new(Vec::new())),
};
let backend = Arc::new(WorkerRuntimeExecutionBackend::new(factory).unwrap());
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone())
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let mut request = create_request("initial-commit");
request.initial_input = Some(WorkerInput::user("start the ticket"));
let detail = runtime.create_worker(request).unwrap();
let entries = backend
.workers
.lock()
.unwrap()
.get(&detail.worker_ref)
.expect("live Worker execution")
.handle
.committed_entries();
assert!(entries.iter().any(|entry| {
matches!(
entry,
LogEntry::UserInput { segments, .. }
if segments == &vec![Segment::text("start the ticket")]
)
}));
let submission_id = entries
.iter()
.find_map(|entry| {
let LogEntry::UserInput { extensions, .. } = entry else {
return None;
};
extensions
.iter()
.find(|extension| extension.domain == WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN)
.and_then(|extension| extension.payload["submission_id"].as_str())
})
.expect("committed input submission id");
uuid::Uuid::parse_str(submission_id).expect("opaque submission id is a UUID");
}
#[test]
fn adapter_dispatches_user_input_through_worker_run_lifecycle() {
let client = MockClient::new(simple_text_events());
+2 -1
View File
@@ -16,7 +16,7 @@ session-store = { workspace = true }
secrets = { workspace = true }
manifest = { workspace = true }
mcp = { workspace = true }
protocol = { workspace = true }
protocol = { workspace = true, features = ["json-schema"] }
client = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
@@ -31,6 +31,7 @@ minijinja = "2.19.0"
chrono = "0.4"
include_dir = "0.7.4"
fs4 = { workspace = true, features = ["sync"] }
flow = { path = "../flow" }
libc = { workspace = true }
schemars = { workspace = true }
ticket = { workspace = true }
+21 -8
View File
@@ -34,8 +34,8 @@ use crate::compact::usage_tracker::UsageTracker;
use crate::fs_view::ReadRequirement;
#[cfg(test)]
use crate::fs_view::slice_lines;
use crate::session_reference::{
ReadDetail, ReadOptions, ReadSelector, SearchOptions, SessionReferenceView, ToolPart,
use crate::session_capture::{
ReadDetail, ReadOptions, ReadSelector, SearchOptions, SessionCapture, ToolPart,
};
/// Aggregated output of a compact worker run.
@@ -150,7 +150,7 @@ this to verify details before writing the summary.";
struct SessionLogToolState {
items: Arc<Vec<Item>>,
view: SessionReferenceView,
view: SessionCapture,
}
struct SearchSessionLogTool {
@@ -185,6 +185,9 @@ impl Tool for SearchSessionLogTool {
tool_name: None,
limit: Some(limit),
min_entry_index: Some(offset as u64),
from: None,
through: None,
offset: 0,
});
let blocks = hits
.iter()
@@ -225,6 +228,8 @@ impl Tool for SearchSessionLogTool {
Ok(ToolOutput {
summary,
content: (!content.is_empty()).then_some(content),
attachments: Vec::new(),
})
}
}
@@ -252,7 +257,7 @@ impl Tool for ReadSessionItemsTool {
SessionReadMode::Full => ReadDetail::Full,
};
let read = if offset >= end {
crate::session_reference::ReadResult {
crate::session_capture::ReadResult {
entries: Vec::new(),
truncated: false,
}
@@ -287,6 +292,8 @@ impl Tool for ReadSessionItemsTool {
Ok(ToolOutput {
summary,
content: (!content.is_empty()).then_some(content),
attachments: Vec::new(),
})
}
}
@@ -392,6 +399,8 @@ impl Tool for MarkReadRequiredTool {
Ok(ToolOutput {
summary,
content: None,
attachments: Vec::new(),
})
}
}
@@ -420,6 +429,8 @@ impl Tool for AddReferenceTool {
Ok(ToolOutput {
summary: format!("Added reference {}", params.file_path.display()),
content: None,
attachments: Vec::new(),
})
}
}
@@ -449,6 +460,8 @@ impl Tool for WriteSummaryTool {
Ok(ToolOutput {
summary: note.to_string(),
content: None,
attachments: Vec::new(),
})
}
}
@@ -496,7 +509,7 @@ pub(crate) fn write_summary_tool(ctx: Arc<Mutex<CompactWorkerContext>>) -> ToolD
}
pub(crate) fn search_session_log_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
let view = SessionReferenceView::new("compact-target", (*items).clone());
let view = SessionCapture::new("compact-target", (*items).clone());
let state = Arc::new(SessionLogToolState { items, view });
Arc::new(move || {
let schema = schemars::schema_for!(SearchSessionParams);
@@ -512,7 +525,7 @@ pub(crate) fn search_session_log_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
}
pub(crate) fn read_session_items_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
let view = SessionReferenceView::new("compact-target", (*items).clone());
let view = SessionCapture::new("compact-target", (*items).clone());
let state = Arc::new(SessionLogToolState { items, view });
Arc::new(move || {
let schema = schemars::schema_for!(ReadSessionParams);
@@ -808,7 +821,7 @@ mod tests {
"very large raw trace body with secret detail",
),
]);
let view = SessionReferenceView::new("test", (*items).clone());
let view = SessionCapture::new("test", (*items).clone());
let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool {
state: Arc::new(SessionLogToolState { items, view }),
});
@@ -828,7 +841,7 @@ mod tests {
"read trace",
"raw trace detail",
)]);
let view = SessionReferenceView::new("test", (*items).clone());
let view = SessionCapture::new("test", (*items).clone());
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
state: Arc::new(SessionLogToolState { items, view }),
});
+108 -12
View File
@@ -5,7 +5,7 @@ use std::sync::atomic::Ordering;
use llm_engine::EngineError;
use llm_engine::llm_client::client::LlmClient;
use session_store::WorkerMetadataStore;
use session_store::{LogEntry, Store};
use session_store::{LogEntry, SessionExtension, Store};
use tokio::sync::{broadcast, mpsc, oneshot};
use crate::discovery::WorkerDiscovery;
@@ -21,12 +21,13 @@ use crate::shutdown_after_idle::{
ShutdownAfterIdleRequest, TicketIntakeReadyShutdownHook, is_ticket_intake_role,
take_shutdown_request_after_status,
};
use crate::spawn::comm_tools::{
sub_worker_list_tool, sub_worker_read_output_tool, sub_worker_send_tool, sub_worker_stop_tool,
};
use crate::spawn::comm_tools::{sub_worker_list_tool, sub_worker_send_tool, sub_worker_stop_tool};
use crate::spawn::registry::SpawnedWorkerRegistry;
use crate::spawn::tool::sub_worker_spawn_tool;
use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult};
use crate::worker::{
SystemItemCommitter, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
WorkerRunResult,
};
use protocol::{
AlertLevel, AlertSource, ErrorCode, Event, Method, RewindTargetId, RunResult, Segment,
TurnResult, WorkerStatus,
@@ -59,6 +60,10 @@ impl WorkerHandle {
self.event_tx.subscribe()
}
pub fn committed_entries(&self) -> Vec<LogEntry> {
self.sink.subscribe_with_snapshot().0
}
pub fn snapshot_event(&self) -> Event {
self.snapshot_event_with_entry_subscription().0
}
@@ -158,6 +163,10 @@ async fn finish_controller_run<C, St>(
/// `worker.run_for_notification()` drains the NotifyBuffer on its own.
enum PendingRun {
Run(Vec<Segment>),
RunTracked {
input: Vec<Segment>,
extension: SessionExtension,
},
/// Self-initiated turn kicked from the notify buffer. The carried
/// `InvokeKind` is the trigger that flipped the Worker from IDLE
/// (Notify or WorkerEvent) and is recorded by the Invoke marker
@@ -175,7 +184,7 @@ impl PendingRun {
/// notify buffer (Notify / inbound WorkerEvent) and stays silent.
fn is_parent_originated(&self) -> bool {
match self {
PendingRun::Run(_) | PendingRun::Resume => true,
PendingRun::Run(_) | PendingRun::RunTracked { .. } | PendingRun::Resume => true,
PendingRun::RunForNotification(_) => false,
}
}
@@ -338,6 +347,7 @@ impl WorkerController {
bash_output_dir,
runtime_base.to_path_buf(),
spawned_registry.clone(),
Some(method_tx.downgrade()),
)
.await?;
@@ -589,6 +599,7 @@ pub(crate) async fn register_worker_tools<C, St>(
bash_output_dir: PathBuf,
runtime_base: PathBuf,
spawned_registry: Arc<SpawnedWorkerRegistry>,
parent_method_tx: Option<mpsc::WeakSender<Method>>,
) -> std::io::Result<Option<workdir::WorkdirSessionHandle>>
where
C: LlmClient + Clone + 'static,
@@ -619,7 +630,11 @@ where
let spawner_name = worker.manifest().worker.name.clone();
let spawner_manifest = worker.manifest().clone();
let spawner_workspace_context = worker.workspace_context_handle();
let parent_notifies = worker.notify_buffer_handle();
let parent_notifications = parent_method_tx
.map(crate::spawn::tool::ParentNotificationTarget::Controller)
.unwrap_or_else(|| {
crate::spawn::tool::ParentNotificationTarget::Buffer(worker.notify_buffer_handle())
});
let prompts = worker.prompts().clone();
// Resolve the existing WorkerWorkdir binding into the domain provider.
// Tools only consume the provider handle; they do not own its root, cwd,
@@ -633,6 +648,12 @@ where
tracker.clone(),
bash_output_dir,
));
if feature_config.image.enabled && model_supports_image_attachments(&spawner_manifest.model)
{
worker
.engine_mut()
.register_tool(tools::view_image_tool(workdir.clone()));
}
(Some(workdir), Some(tracker))
} else {
(None, None)
@@ -725,6 +746,7 @@ where
worker.register_worker_orchestration_instruction();
}
let host_worker_observation_provider = worker.worker_observation_provider();
{
let workspace_client = worker.workspace_client_handle();
let engine = worker.engine_mut();
@@ -779,7 +801,11 @@ where
}
}
// Worker-orchestration tools (SubWorkerSpawn + the four comm tools) share
let mut observation_providers: Vec<
Arc<dyn crate::feature::builtin::worker_observation::WorkerObservationProvider>,
> = Vec::new();
// Worker-orchestration tools (SubWorkerSpawn + three control tools) share
// the Worker-scoped `SpawnedWorkerRegistry` (also consumed by the main
// loop's `WorkerEvent` handler). Expose them only behind the explicit
// profile feature and require delegation authority up front so enabling
@@ -803,7 +829,7 @@ where
engine.register_tool(sub_worker_spawn_tool(
spawner_name.clone(),
spawner_workspace_context,
parent_notifies,
parent_notifications,
runtime_base.clone(),
spawner_workspace_root,
spawner_cwd.clone(),
@@ -814,8 +840,26 @@ where
));
engine.register_tool(sub_worker_list_tool(spawned_registry.clone()));
engine.register_tool(sub_worker_send_tool(spawned_registry.clone()));
engine.register_tool(sub_worker_read_output_tool(spawned_registry.clone()));
engine.register_tool(sub_worker_stop_tool(spawned_registry));
engine.register_tool(sub_worker_stop_tool(spawned_registry.clone()));
observation_providers.push(Arc::new(
crate::feature::builtin::worker_observation::SpawnedSubWorkerObservationProvider::new(
spawned_registry,
),
));
}
if let Some(provider) = host_worker_observation_provider {
observation_providers.push(provider);
}
if !observation_providers.is_empty() {
feature_registry = feature_registry.with_module(
crate::feature::builtin::worker_observation::WorkerObservationFeature::new(
Arc::new(
crate::feature::builtin::worker_observation::CompositeWorkerObservationProvider::new(
observation_providers,
),
),
),
);
}
}
let _feature_install_report = worker.install_features(feature_registry);
@@ -908,6 +952,21 @@ async fn controller_loop<C, St>(
)
.await
}
PendingRun::RunTracked { input, extension } => {
drive_turn(
worker.run_with_input_extensions(input, vec![extension]),
&mut method_rx,
&event_tx,
&cancel_tx,
&shared_state,
&notify_buffer,
self_parent_socket.as_ref(),
&spawner_name,
&spawned_registry,
parent_originated,
)
.await
}
PendingRun::RunForNotification(kind) => {
drive_turn(
worker.run_for_notification(kind),
@@ -993,6 +1052,19 @@ async fn controller_loop<C, St>(
pending = Some(PendingRun::Run(input));
}
Method::RunTracked {
input,
submission_id,
} => {
// Runtime-correlated submissions retain their opaque id in the
// same durable UserInput record used for Flow state.
let extension = SessionExtension::new(
WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN,
serde_json::json!({ "submission_id": submission_id }),
);
pending = Some(PendingRun::RunTracked { input, extension });
}
Method::Notify { message, auto_run } => {
// Client-side live echo is delivered as `Event::SystemItem`
// once the interceptor commits the corresponding
@@ -1386,7 +1458,7 @@ where
shutdown_requested = true;
let _ = cancel_tx.try_send(());
}
Some(Method::Run { .. } | Method::Resume) => {
Some(Method::Run { .. } | Method::RunTracked { .. } | Method::Resume) => {
let _ = event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn".into(),
@@ -1504,6 +1576,16 @@ where
}
}
fn model_supports_image_attachments(model: &manifest::ModelManifest) -> bool {
manifest::model_catalog::resolve_model_manifest(model).is_ok_and(|model| {
model.capability.is_some_and(|capability| capability.vision)
&& matches!(
model.scheme,
manifest::SchemeKind::OpenaiChat | manifest::SchemeKind::OpenaiResponses
)
})
}
fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
where
C: LlmClient,
@@ -1583,6 +1665,20 @@ mod tests {
use tempfile::TempDir;
use tokio::net::UnixListener;
#[test]
fn image_attachment_gate_requires_vision_and_supported_openai_scheme() {
let openai = manifest::ModelManifest {
ref_: Some("codex-oauth/gpt-5.6-sol".to_string()),
..Default::default()
};
let anthropic = manifest::ModelManifest {
ref_: Some("anthropic/claude-opus-4-8".to_string()),
..Default::default()
};
assert!(model_supports_image_attachments(&openai));
assert!(!model_supports_image_attachments(&anthropic));
}
#[test]
fn pending_run_parent_origin_table() {
assert!(PendingRun::Run(Vec::new()).is_parent_originated());
+4
View File
@@ -854,6 +854,7 @@ where
Ok(ToolOutput {
summary,
content: Some(json_content(&items)?),
attachments: Vec::new(),
})
}
}
@@ -890,6 +891,8 @@ where
Ok(ToolOutput {
summary,
content: Some(json_content(&result)?),
attachments: Vec::new(),
})
}
}
@@ -982,6 +985,7 @@ where
Ok(ToolOutput {
summary: format!("sent peer message to `{}`", input.name),
content: None,
attachments: Vec::new(),
})
}
}
+10 -3
View File
@@ -4,19 +4,26 @@
//! same descriptor-approved registry path used by feature modules. They are not
//! an external plugin-loading surface.
pub mod flow_transition;
pub mod manage_workdir;
pub mod manage_worker;
pub mod memory;
pub mod memory_extract;
pub mod objective;
pub mod session_explore;
pub mod task;
pub mod ticket;
pub mod worker_observation;
pub(crate) use session_explore::{
SessionExploreFeature, SessionExploreState, render_extract_input,
};
pub(crate) use memory_extract::{MemoryExtractFeature, MemoryExtractState, render_extract_input};
pub(crate) use session_explore::{SessionExploreFeature, SessionExploreState};
pub use task::{TaskFeature, task_tools_feature};
pub use ticket::{
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
ticket_tools_feature_with_backend,
};
pub use worker_observation::{
CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationFeature,
WorkerObservationProvider, WorkerObservationSubject, WorkerObservationSubjectRef,
WorkerSessionCapture, WorkspaceClientWorkerObservationProvider,
};
File diff suppressed because it is too large Load Diff
@@ -492,6 +492,7 @@ fn workdir_output<T: Serialize>(summary: String, value: &T) -> Result<ToolOutput
Ok(ToolOutput {
summary,
content: Some(serde_json::to_string_pretty(value).map_err(decode_error)?),
attachments: Vec::new(),
})
}
@@ -9,6 +9,8 @@ use llm_engine::tool::{
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use protocol::Segment;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
@@ -94,20 +96,33 @@ struct WorkerSpawnInput {
runtime_id: String,
working_directory_id: String,
profile: String,
/// Optional queued Ticket to assign atomically to the new Coder Worker.
#[serde(default)]
ticket_id: Option<String>,
#[serde(default)]
display_name: Option<String>,
/// Normal typed initial user submission delivered after spawn. An empty
/// vector starts the Worker without initial input.
#[serde(default)]
initial_text: Option<String>,
initial_submit: Vec<Segment>,
#[serde(default)]
relative_cwd: Option<String>,
}
#[derive(Debug, Serialize)]
struct WorkerSpawnTicketAssignmentRequest {
ticket_id: String,
operation_id: String,
}
#[derive(Debug, Serialize)]
struct WorkerSpawnRequest {
runtime_id: String,
display_name: String,
profile: String,
initial_text: String,
#[serde(skip_serializing_if = "Option::is_none")]
ticket_assignment: Option<WorkerSpawnTicketAssignmentRequest>,
initial_submit: Vec<Segment>,
working_directory: WorkerWorkingDirectorySelection,
}
@@ -166,7 +181,7 @@ impl WorkerOperation {
"List Backend/Runtime Worker sessions in the current Workspace. SubWorkers are excluded."
}
Self::Spawn => {
"Spawn a Backend/Runtime Worker session in an existing Workspace Workdir. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted."
"Spawn a Backend/Runtime Worker session in an existing Workspace Workdir. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. Set `ticket_id` with a Flow segment in `initial_submit` to atomically assign a queued Ticket to the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
}
Self::Stop => "Stop a Backend/Runtime Worker session in the current Workspace.",
Self::Restore => {
@@ -181,7 +196,7 @@ impl Tool for WorkspaceWorkerTool {
async fn execute(
&self,
input_json: &str,
_ctx: ToolExecutionContext,
ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let request = match self.operation {
WorkerOperation::List => {
@@ -190,6 +205,17 @@ impl Tool for WorkspaceWorkerTool {
}
WorkerOperation::Spawn => {
let input = parse::<WorkerSpawnInput>(input_json, "WorkerSpawn")?;
let ticket_assignment = input
.ticket_id
.map(|ticket_id| {
let ticket_id = authority_id(&ticket_id, "ticket_id")?;
let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?;
Ok::<_, ToolError>(WorkerSpawnTicketAssignmentRequest {
operation_id: format!("worker-spawn:{ticket_id}:{call_id}"),
ticket_id,
})
})
.transpose()?;
let request = WorkerSpawnRequest {
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
display_name: input
@@ -197,7 +223,8 @@ impl Tool for WorkspaceWorkerTool {
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "Workspace Worker".to_string()),
profile: non_empty(input.profile, "profile")?,
initial_text: input.initial_text.unwrap_or_default(),
ticket_assignment,
initial_submit: input.initial_submit,
working_directory: WorkerWorkingDirectorySelection {
working_directory_id: authority_id(
&input.working_directory_id,
@@ -256,6 +283,7 @@ impl Tool for WorkspaceWorkerTool {
Ok(ToolOutput {
summary: format!("{} completed", self.operation.tool_name()),
content: Some(response.body),
attachments: Vec::new(),
})
}
}
@@ -320,7 +348,86 @@ fn validate_relative_cwd(value: &str) -> Result<String, ToolError> {
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
#[derive(Debug, Default)]
struct RecordingWorkspaceClient {
requests: Mutex<Vec<WorkspaceRequest>>,
}
impl WorkspaceClient for RecordingWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace/test")
}
fn kind(&self) -> &str {
"recording"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
status: 200,
body: "{}".to_string(),
})
}
}
#[tokio::test]
async fn worker_spawn_forwards_typed_initial_submit_to_workspace_api() {
let client = Arc::new(RecordingWorkspaceClient::default());
let tool = WorkspaceWorkerTool {
operation: WorkerOperation::Spawn,
client: client.clone(),
workspace_id: "workspace%2Ftest".to_string(),
};
tool.execute(
&serde_json::json!({
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1",
"profile": "builtin:coder",
"ticket_id": "00001KZ9E0DBS",
"initial_submit": [
{ "kind": "flow", "selector": "builtin:coder-review" },
{ "kind": "text", "content": "Implement Ticket 00001" }
]
})
.to_string(),
ToolExecutionContext::new("call-1", "batch-1", 0),
)
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].path, "/api/w/workspace%2Ftest/workers");
let body: serde_json::Value =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["initial_submit"][0]["kind"], "flow");
assert_eq!(
body["initial_submit"][0]["selector"],
"builtin:coder-review"
);
assert_eq!(body["initial_submit"][1]["kind"], "text");
assert_eq!(
body["ticket_assignment"],
serde_json::json!({
"ticket_id": "00001KZ9E0DBS",
"operation_id": "worker-spawn:00001KZ9E0DBS:call-1"
})
);
assert!(body.get("initial_text").is_none());
}
#[test]
fn worker_tool_family_is_distinct_from_sub_worker_tools() {
@@ -330,13 +437,30 @@ mod tests {
);
}
#[test]
fn worker_spawn_schema_exposes_normal_typed_segment_variants() {
let schema = serde_json::to_value(schemars::schema_for!(WorkerSpawnInput)).unwrap();
let text = serde_json::to_string(&schema).unwrap();
assert!(text.contains("initial_submit"));
assert!(text.contains("ticket_id"));
assert!(text.contains("selector"));
assert!(text.contains("flow"));
assert!(!text.contains("initial_text"));
}
#[test]
fn worker_spawn_request_uses_authority_ids_without_runtime_paths() {
let request = WorkerSpawnRequest {
runtime_id: "runtime-1".to_string(),
display_name: "Coder".to_string(),
profile: "builtin:coder".to_string(),
initial_text: "Implement the Ticket".to_string(),
ticket_assignment: None,
initial_submit: vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::text("Implement the Ticket"),
],
working_directory: WorkerWorkingDirectorySelection {
working_directory_id: "wd-1".to_string(),
relative_cwd: Some("repo".to_string()),
@@ -348,6 +472,13 @@ mod tests {
assert!(value.get("cwd").is_none());
assert!(value.get("runtime_url").is_none());
assert!(value["working_directory"].get("mode").is_none());
assert_eq!(value["initial_submit"][0]["kind"], "flow");
assert_eq!(
value["initial_submit"][0]["selector"],
"builtin:coder-review"
);
assert_eq!(value["initial_submit"][1]["kind"], "text");
assert!(value.get("initial_text").is_none());
}
#[test]
@@ -292,6 +292,7 @@ fn tool_output(output: MemoryToolOutput) -> ToolOutput {
ToolOutput {
summary: output.summary,
content: output.content,
attachments: Vec::new(),
}
}
@@ -0,0 +1,460 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use memory::backend::{
MemoryBackendOperation, MemoryBackendOperationResult, MemoryStageCandidateOperation,
};
use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence};
use memory::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
use crate::session_capture::{
ReferenceKind, SearchOptions, SessionCapture, SessionEntryEvidence, ToolPart,
};
use crate::worker::WorkspaceClient;
use super::memory::WorkspaceMemoryBackendError;
const STAGE_DESCRIPTION: &str = "Stage one durable Memory candidate using SessionEntryRef values from the co-installed session-explore capture.";
const FINISH_DESCRIPTION: &str =
"Finish Memory extraction after validating the number of candidates staged during this run.";
#[derive(Clone)]
pub(crate) struct MemoryExtractState {
view: Arc<SessionCapture>,
workspace_client: Arc<dyn WorkspaceClient>,
source: SourceRef,
extract_run_id: String,
staged: Arc<Mutex<Vec<String>>>,
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
}
impl MemoryExtractState {
pub(crate) fn new(
view: SessionCapture,
workspace_client: Arc<dyn WorkspaceClient>,
source: SourceRef,
extract_run_id: String,
) -> Self {
Self {
view: Arc::new(view),
workspace_client,
source,
extract_run_id,
staged: Arc::new(Mutex::new(Vec::new())),
finished: Arc::new(Mutex::new(None)),
}
}
pub(crate) fn staged(&self) -> Vec<String> {
self.staged
.lock()
.expect("memory extract staged state poisoned")
.clone()
}
pub(crate) fn is_finished(&self) -> bool {
self.finished
.lock()
.expect("memory extract finished state poisoned")
.is_some()
}
}
#[derive(Clone)]
pub(crate) struct MemoryExtractFeature {
state: MemoryExtractState,
}
impl MemoryExtractFeature {
pub(crate) fn new(state: MemoryExtractState) -> Self {
Self { state }
}
}
impl FeatureModule for MemoryExtractFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("memory-extract", "Memory Extract")
.with_description(
"Memory staging and extraction completion, independent from session exploration.",
)
.with_tool(ToolDeclaration::new(
"StageMemoryCandidate",
STAGE_DESCRIPTION,
))
.with_tool(ToolDeclaration::new(
"FinishMemoryExtraction",
FINISH_DESCRIPTION,
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.tools().register(ToolContribution::new(
"StageMemoryCandidate",
stage_definition(self.state.clone()),
))?;
context.tools().register(ToolContribution::new(
"FinishMemoryExtraction",
finish_definition(self.state.clone()),
))?;
Ok(())
}
}
fn stage_definition(state: MemoryExtractState) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(StageMemoryCandidateParams))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new("StageMemoryCandidate")
.description(STAGE_DESCRIPTION)
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(StageMemoryCandidateTool {
state: state.clone(),
});
(meta, tool)
})
}
fn finish_definition(state: MemoryExtractState) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(FinishMemoryExtractionParams))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new("FinishMemoryExtraction")
.description(FINISH_DESCRIPTION)
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(FinishMemoryExtractionTool {
state: state.clone(),
});
(meta, tool)
})
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct StageMemoryCandidateParams {
kind: CandidateKind,
claim: String,
why_useful: String,
#[serde(default)]
staleness: Option<String>,
entry_refs: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct FinishMemoryExtractionParams {
staged_count: usize,
#[serde(default)]
no_candidates_reason: Option<String>,
}
struct StageMemoryCandidateTool {
state: MemoryExtractState,
}
#[async_trait]
impl Tool for StageMemoryCandidateTool {
async fn execute(
&self,
input_json: &str,
_context: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: StageMemoryCandidateParams =
serde_json::from_str(input_json).map_err(|error| {
ToolError::InvalidArgument(format!("invalid StageMemoryCandidate input: {error}"))
})?;
if params.entry_refs.is_empty() {
return Err(ToolError::InvalidArgument(
"StageMemoryCandidate requires at least one entry_ref".to_string(),
));
}
let mut evidence = Vec::with_capacity(params.entry_refs.len());
let mut source_refs = Vec::with_capacity(params.entry_refs.len());
for entry_ref in &params.entry_refs {
let projection = self.state.view.evidence_for(entry_ref).ok_or_else(|| {
ToolError::InvalidArgument(format!(
"unknown SessionEntryRef {entry_ref:?} for this extraction capture"
))
})?;
evidence.push(staging_evidence(&projection));
source_refs.push(source_evidence_ref(&projection));
}
let candidate = ExtractedCandidate {
kind: params.kind,
claim: params.claim,
why_useful: params.why_useful,
staleness: params.staleness,
evidence_ids: params.entry_refs,
};
let result = self
.state
.workspace_client
.execute_memory_backend_operation(MemoryBackendOperation::StageCandidate(
MemoryStageCandidateOperation {
source: self.state.source.clone(),
extract_run_id: self.state.extract_run_id.clone(),
candidate,
evidence,
source_refs,
},
))
.await
.map_err(map_memory_stage_error)?;
let staging_ids = match result {
MemoryBackendOperationResult::StagingWritten(output) if output.staging_count == 1 => {
output.staging_ids
}
MemoryBackendOperationResult::StagingWritten(output) => {
return Err(ToolError::ExecutionFailed(format!(
"StageMemoryCandidate expected one staging record, backend wrote {}",
output.staging_count
)));
}
other => {
return Err(ToolError::ExecutionFailed(format!(
"unexpected Memory backend result for StageMemoryCandidate: {other:?}"
)));
}
};
let staging_id = staging_ids.into_iter().next().ok_or_else(|| {
ToolError::ExecutionFailed(
"StageMemoryCandidate backend did not return a staging id".to_string(),
)
})?;
self.state
.staged
.lock()
.expect("memory extract staged state poisoned")
.push(staging_id.clone());
Ok(ToolOutput {
summary: format!("Staged Memory candidate {staging_id}."),
content: Some(format!("staging_id: {staging_id}")),
attachments: Vec::new(),
})
}
}
struct FinishMemoryExtractionTool {
state: MemoryExtractState,
}
#[async_trait]
impl Tool for FinishMemoryExtractionTool {
async fn execute(
&self,
input_json: &str,
_context: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: FinishMemoryExtractionParams =
serde_json::from_str(input_json).map_err(|error| {
ToolError::InvalidArgument(format!("invalid FinishMemoryExtraction input: {error}"))
})?;
let actual = self
.state
.staged
.lock()
.expect("memory extract staged state poisoned")
.len();
if params.staged_count != actual {
return Err(ToolError::InvalidArgument(format!(
"FinishMemoryExtraction staged_count {} does not match actual staged count {actual}",
params.staged_count
)));
}
let reason = params.no_candidates_reason.clone();
*self
.state
.finished
.lock()
.expect("memory extract finished state poisoned") = Some(params);
Ok(ToolOutput {
summary: reason
.map(|reason| {
format!("Finished extraction with {actual} staged candidate(s): {reason}")
})
.unwrap_or_else(|| {
format!("Finished extraction with {actual} staged candidate(s).")
}),
content: None,
attachments: Vec::new(),
})
}
}
fn map_memory_stage_error(error: WorkspaceMemoryBackendError) -> ToolError {
match error {
WorkspaceMemoryBackendError::Backend(message) => ToolError::InvalidArgument(message),
WorkspaceMemoryBackendError::Http { status, body }
if matches!(
status,
reqwest::StatusCode::BAD_REQUEST | reqwest::StatusCode::UNPROCESSABLE_ENTITY
) =>
{
ToolError::InvalidArgument(body)
}
error => ToolError::ExecutionFailed(format!("write Memory staging failed: {error}")),
}
}
fn evidence_kind(entry: &SessionEntryEvidence) -> EvidenceKind {
match (entry.kind, entry.tool_part) {
(ReferenceKind::Tool, Some(ToolPart::Input)) => EvidenceKind::new(EvidenceKind::TOOL_CALL),
(ReferenceKind::Tool, _) => EvidenceKind::new(EvidenceKind::TOOL_RESULT),
_ => EvidenceKind::new(EvidenceKind::MESSAGE),
}
}
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
StagingEvidence {
id: entry.entry_ref.to_string(),
kind: evidence_kind(entry),
entry_range: Some(entry.entry_range),
excerpt: Some(entry.excerpt.clone()),
summary: Some(entry.summary.clone()),
}
}
fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef {
SourceEvidenceRef {
segment_id: Some(entry.segment_id.clone()),
entry_range: Some(entry.entry_range),
evidence_id: Some(entry.entry_ref.to_string()),
evidence_kind: Some(evidence_kind(entry)),
label: Some(entry.label.clone()),
summary: Some(entry.summary.clone()),
..Default::default()
}
}
pub(crate) fn render_extract_input(view: &SessionCapture) -> String {
let mut output = String::from("# Session overview\n\n");
if view.overview().is_empty() {
output.push_str("No user/assistant overview entries are available.\n\n");
} else {
for item in view.overview() {
output.push_str(&format!(
"- [{} {}] {}\n {}\n intervening_entries: {}\n",
item.id,
item.kind.as_str(),
item.label,
truncate_line(&item.text, 500),
item.intervening_entries,
));
}
output.push('\n');
}
output.push_str("# Initial session entry index\n\n");
output.push_str("Use ShowOverview, SearchEntries, and ReadEntry to inspect details. Cite only SessionEntryRef values in StageMemoryCandidate.entry_refs.\n\n");
let hits = view.search(&SearchOptions {
query: String::new(),
kind: None,
tool_part: None,
tool_name: None,
limit: Some(50),
min_entry_index: None,
from: None,
through: None,
offset: 0,
});
for hit in hits {
output.push_str(&format!(
"- [{} {}] {} — {}\n",
hit.id,
hit.kind.as_str(),
hit.label,
hit.summary
));
}
output
}
fn truncate_line(text: &str, max_chars: usize) -> String {
let normalized = text.replace('\n', " ");
if normalized.chars().count() <= max_chars {
normalized
} else {
let mut output = normalized.chars().take(max_chars).collect::<String>();
output.push('…');
output
}
}
#[cfg(test)]
mod tests {
use llm_engine::Item;
use super::*;
fn state() -> MemoryExtractState {
MemoryExtractState::new(
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
crate::worker::marker_workspace_client(None, "test-backend"),
SourceRef {
segment_id: "segment-1".to_string(),
range: [0, 0],
},
"run-1".to_string(),
)
}
#[test]
fn memory_extract_declares_only_memory_mutation_tools() {
let descriptor = MemoryExtractFeature::new(state()).descriptor();
assert_eq!(descriptor.id.as_str(), "builtin:memory-extract");
assert_eq!(
descriptor
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
vec!["StageMemoryCandidate", "FinishMemoryExtraction"]
);
}
#[test]
fn render_input_uses_session_entry_refs_and_new_tool_names() {
let view = SessionCapture::new(
"segment-1",
vec![
Item::user_message("preference"),
Item::tool_call("call-1", "Read", "{}"),
],
);
let input = render_extract_input(&view);
assert!(input.contains("E00000000"));
assert!(input.contains("E00000001"));
assert!(input.contains("StageMemoryCandidate.entry_refs"));
}
#[test]
fn backend_input_failures_remain_invalid_argument_tool_errors() {
let backend = map_memory_stage_error(WorkspaceMemoryBackendError::Backend(
"invalid candidate".to_string(),
));
assert!(matches!(backend, ToolError::InvalidArgument(_)));
let http = map_memory_stage_error(WorkspaceMemoryBackendError::Http {
status: reqwest::StatusCode::UNPROCESSABLE_ENTITY,
body: "invalid candidate".to_string(),
});
assert!(matches!(http, ToolError::InvalidArgument(_)));
}
#[tokio::test]
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
let tool = StageMemoryCandidateTool { state: state() };
let error = tool
.execute(
r#"{"kind":"decision","claim":"claim","why_useful":"useful","entry_refs":["E00000009"]}"#,
llm_engine::tool::ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(format!("{error:?}").contains("unknown SessionEntryRef"));
}
}
@@ -41,6 +41,8 @@ impl WorkspaceHttpObjectiveBackend {
Ok(ToolOutput {
summary: format!("Listed {count} objective(s)"),
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
attachments: Vec::new(),
})
}
@@ -249,6 +251,8 @@ fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOu
Ok(ToolOutput {
summary,
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
attachments: Vec::new(),
})
}
File diff suppressed because it is too large Load Diff
@@ -111,6 +111,8 @@ impl Tool for TaskListTool {
Ok(ToolOutput {
summary: list_overview(active_tasks.len(), tasks.len()),
content: Some(render_task_list(&tasks)),
attachments: Vec::new(),
})
}
}
@@ -131,6 +133,8 @@ impl Tool for TaskGetTool {
Ok(ToolOutput {
summary: format!("Task {} ({}) {}", task.taskid, task.status, task.subject),
content: Some(content),
attachments: Vec::new(),
})
}
}
@@ -170,6 +174,8 @@ fn task_output(summary: String, task: &TaskEntry) -> ToolOutput {
ToolOutput {
summary,
content: Some(serde_json::to_string_pretty(task).unwrap_or_default()),
attachments: Vec::new(),
}
}
@@ -0,0 +1,882 @@
use std::sync::Arc;
use async_trait::async_trait;
use llm_engine::Item;
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use session_store::collect_state;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution,
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ToolContribution,
ToolDeclaration,
};
use crate::session_capture::{
ReadDetail, ReadOptions, ReadSelector, ReferenceKind, SearchOptions, SessionCapture,
SessionEntryRef, ToolPart,
};
use crate::spawn::registry::SpawnedWorkerRegistry;
const MAX_SUBJECTS: usize = 100;
const DEFAULT_PAGE_LIMIT: usize = 20;
const MAX_PAGE_LIMIT: usize = 100;
const MAX_READ_BYTES: usize = 16 * 1024;
const OBSERVATION_INSTRUCTION_ID: &str = "worker-observation.policy";
const OBSERVATION_PROMPT_REF: &str = "$yoi/common/worker-observation";
#[cfg(test)]
const OBSERVATION_PROMPT_SOURCE: &str =
include_str!("../../../../../resources/prompts/common/worker-observation.md");
fn observation_instruction() -> FeatureInstructionDeclaration {
FeatureInstructionDeclaration::new(
FeatureInstructionId::builtin(OBSERVATION_INSTRUCTION_ID),
OBSERVATION_PROMPT_REF,
"Worker session observation authority and privacy policy",
)
.expect("static worker-observation instruction declaration is valid")
}
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum WorkerObservationSubjectRef {
RuntimeWorker {
runtime_id: String,
worker_id: String,
},
SubWorker {
name: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerObservationSubject {
pub subject: WorkerObservationSubjectRef,
pub display_name: String,
pub relation: String,
pub status: String,
}
#[derive(Debug, Clone)]
pub struct WorkerSessionCapture {
pub segment_id: String,
pub items: Vec<Item>,
}
#[derive(Debug, thiserror::Error)]
pub enum WorkerObservationError {
#[error("worker session was not found or is not accessible")]
NotFound,
#[error("worker session observation failed: {0}")]
Unavailable(String),
}
#[async_trait]
pub trait WorkerObservationProvider: Send + Sync {
/// Returns only subjects already authorized for the current Worker.
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError>;
/// Reauthorizes and captures the latest committed session for one subject.
/// Unauthorized and missing subjects must both return `NotFound`.
async fn capture_worker_session(
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError>;
}
#[derive(Debug, Deserialize)]
struct WorkspaceWorkerObservationListResponse {
sessions: Vec<WorkerObservationSubject>,
}
#[derive(Debug, Deserialize)]
struct WorkspaceWorkerObservationCaptureResponse {
segment_id: String,
entries: Vec<serde_json::Value>,
}
pub struct WorkspaceClientWorkerObservationProvider {
client: Arc<dyn crate::worker::WorkspaceClient>,
}
impl WorkspaceClientWorkerObservationProvider {
pub fn new(client: Arc<dyn crate::worker::WorkspaceClient>) -> Self {
Self { client }
}
}
#[async_trait]
impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
let response = self
.client
.execute(crate::worker::WorkspaceRequest::get(
"/worker-observation/sessions",
))
.map_err(workspace_client_error)?;
let body = workspace_response_body(response)?;
serde_json::from_str::<WorkspaceWorkerObservationListResponse>(&body)
.map(|response| response.sessions)
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))
}
async fn capture_worker_session(
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError> {
let body = serde_json::to_string(subject)
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))?;
let response = self
.client
.execute(crate::worker::WorkspaceRequest::json(
crate::worker::WorkspaceRequestMethod::Post,
"/worker-observation/session",
body,
))
.map_err(workspace_client_error)?;
let body = workspace_response_body(response)?;
let response = serde_json::from_str::<WorkspaceWorkerObservationCaptureResponse>(&body)
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))?;
let entries = response
.entries
.into_iter()
.map(|entry| {
serde_json::from_value(entry)
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))
})
.collect::<Result<Vec<session_store::LogEntry>, _>>()?;
let state = collect_state(&entries);
Ok(WorkerSessionCapture {
segment_id: response.segment_id,
items: state.history,
})
}
}
fn workspace_response_body(
response: crate::worker::WorkspaceResponse,
) -> Result<String, WorkerObservationError> {
match response.status {
200..=299 => Ok(response.body),
403 | 404 => Err(WorkerObservationError::NotFound),
status => Err(WorkerObservationError::Unavailable(format!(
"Workspace observation request failed with status {status}: {}",
response.body
))),
}
}
fn workspace_client_error(error: crate::worker::WorkspaceClientError) -> WorkerObservationError {
WorkerObservationError::Unavailable(error.to_string())
}
#[derive(Clone)]
pub struct WorkerObservationFeature {
provider: Arc<dyn WorkerObservationProvider>,
}
impl WorkerObservationFeature {
pub fn new(provider: Arc<dyn WorkerObservationProvider>) -> Self {
Self { provider }
}
}
impl FeatureModule for WorkerObservationFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("worker-observation", "Worker Observation")
.with_description(
"Read-only exploration of explicitly granted active Worker sessions.",
)
.with_instruction(observation_instruction())
.with_tool(ToolDeclaration::new(
"ListWorkerSessions",
"List bounded summaries of active Worker sessions granted to this Worker.",
))
.with_tool(ToolDeclaration::new(
"ViewSessionOverview",
"Show a sparse overview of the latest committed capture for one granted Worker session.",
))
.with_tool(ToolDeclaration::new(
"SearchSessionEntries",
"Search or compactly list a bounded range in one granted Worker session.",
))
.with_tool(ToolDeclaration::new(
"ReadSessionEntry",
"Read one committed entry from one granted Worker session by SessionEntryRef.",
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context
.instructions()
.register(FeatureInstructionContribution::new(
observation_instruction(),
))?;
context.tools().register(ToolContribution::new(
"ListWorkerSessions",
list_definition(self.provider.clone()),
))?;
context.tools().register(ToolContribution::new(
"ViewSessionOverview",
overview_definition(self.provider.clone()),
))?;
context.tools().register(ToolContribution::new(
"SearchSessionEntries",
search_definition(self.provider.clone()),
))?;
context.tools().register(ToolContribution::new(
"ReadSessionEntry",
read_definition(self.provider.clone()),
))?;
Ok(())
}
}
pub struct CompositeWorkerObservationProvider {
providers: Vec<Arc<dyn WorkerObservationProvider>>,
}
impl CompositeWorkerObservationProvider {
pub fn new(providers: Vec<Arc<dyn WorkerObservationProvider>>) -> Self {
Self { providers }
}
}
#[async_trait]
impl WorkerObservationProvider for CompositeWorkerObservationProvider {
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
let mut seen = std::collections::HashSet::new();
let mut subjects = Vec::new();
let mut unavailable = None;
for provider in &self.providers {
let provider_subjects = match provider.list_worker_sessions().await {
Ok(subjects) => subjects,
Err(WorkerObservationError::NotFound) => continue,
Err(error) => {
unavailable.get_or_insert(error);
continue;
}
};
for subject in provider_subjects {
if seen.insert(subject.subject.clone()) {
subjects.push(subject);
if subjects.len() == MAX_SUBJECTS {
return Ok(subjects);
}
}
}
}
if subjects.is_empty() {
if let Some(error) = unavailable {
return Err(error);
}
}
Ok(subjects)
}
async fn capture_worker_session(
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError> {
for provider in &self.providers {
match provider.capture_worker_session(subject).await {
Ok(capture) => return Ok(capture),
Err(WorkerObservationError::NotFound) => continue,
Err(error) => return Err(error),
}
}
Err(WorkerObservationError::NotFound)
}
}
pub(crate) struct SpawnedSubWorkerObservationProvider {
registry: Arc<SpawnedWorkerRegistry>,
}
impl SpawnedSubWorkerObservationProvider {
pub(crate) fn new(registry: Arc<SpawnedWorkerRegistry>) -> Self {
Self { registry }
}
}
#[async_trait]
impl WorkerObservationProvider for SpawnedSubWorkerObservationProvider {
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
let subjects = self
.registry
.list_internal()
.into_iter()
.take(MAX_SUBJECTS)
.map(|record| WorkerObservationSubject {
subject: WorkerObservationSubjectRef::SubWorker {
name: record.worker_name.clone(),
},
display_name: record.worker_name,
relation: "subworker".to_string(),
status: format!("{:?}", record.session.status()).to_lowercase(),
})
.collect();
Ok(subjects)
}
async fn capture_worker_session(
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError> {
let WorkerObservationSubjectRef::SubWorker { name } = subject else {
return Err(WorkerObservationError::NotFound);
};
let record = self
.registry
.get_internal(name)
.ok_or(WorkerObservationError::NotFound)?;
let entries = record.session.entries();
let state = collect_state(&entries);
Ok(WorkerSessionCapture {
segment_id: format!("subworker:{name}"),
items: state.history,
})
}
}
fn list_definition(provider: Arc<dyn WorkerObservationProvider>) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(ListWorkerSessionsParams))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new("ListWorkerSessions")
.description("List active Worker sessions explicitly granted to this Worker.")
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(ListWorkerSessionsTool {
provider: provider.clone(),
});
(meta, tool)
})
}
fn overview_definition(provider: Arc<dyn WorkerObservationProvider>) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(ViewSessionOverviewParams))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new("ViewSessionOverview")
.description("Show a sparse bounded index for one granted Worker session.")
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(ViewSessionOverviewTool {
provider: provider.clone(),
});
(meta, tool)
})
}
fn search_definition(provider: Arc<dyn WorkerObservationProvider>) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(SearchSessionEntriesParams))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new("SearchSessionEntries")
.description("Search or list a bounded range in one granted Worker session.")
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(SearchSessionEntriesTool {
provider: provider.clone(),
});
(meta, tool)
})
}
fn read_definition(provider: Arc<dyn WorkerObservationProvider>) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(ReadSessionEntryParams))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new("ReadSessionEntry")
.description("Read one entry by SessionEntryRef from one granted Worker session.")
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(ReadSessionEntryTool {
provider: provider.clone(),
});
(meta, tool)
})
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ListWorkerSessionsParams {
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ViewSessionOverviewParams {
subject: WorkerObservationSubjectRef,
#[serde(default)]
offset: usize,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SearchSessionEntriesParams {
subject: WorkerObservationSubjectRef,
#[serde(default)]
query: String,
#[serde(default)]
kind: Option<String>,
#[serde(default)]
tool_part: Option<String>,
#[serde(default)]
tool_name: Option<String>,
#[serde(default)]
from: Option<String>,
#[serde(default)]
through: Option<String>,
#[serde(default)]
offset: usize,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ReadSessionEntryParams {
subject: WorkerObservationSubjectRef,
entry_ref: String,
#[serde(default = "default_read_mode")]
mode: String,
}
fn default_read_mode() -> String {
"compact".to_string()
}
struct ListWorkerSessionsTool {
provider: Arc<dyn WorkerObservationProvider>,
}
#[async_trait]
impl Tool for ListWorkerSessionsTool {
async fn execute(
&self,
input_json: &str,
_context: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: ListWorkerSessionsParams = parse_input("ListWorkerSessions", input_json)?;
let limit = bounded_limit(params.limit);
let mut subjects = self
.provider
.list_worker_sessions()
.await
.map_err(tool_error)?;
subjects.truncate(limit);
let sessions = subjects
.iter()
.map(|subject| {
serde_json::json!({
"subject": bounded_subject(&subject.subject),
"display_name": truncate_text(&subject.display_name, 200),
"relation": truncate_text(&subject.relation, 64),
"status": truncate_text(&subject.status, 64),
})
})
.collect::<Vec<_>>();
json_output(
format!("Listed {} Worker session(s).", sessions.len()),
serde_json::json!({ "sessions": sessions }),
)
}
}
struct ViewSessionOverviewTool {
provider: Arc<dyn WorkerObservationProvider>,
}
#[async_trait]
impl Tool for ViewSessionOverviewTool {
async fn execute(
&self,
input_json: &str,
_context: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: ViewSessionOverviewParams = parse_input("ViewSessionOverview", input_json)?;
let view = latest_view(&*self.provider, &params.subject).await?;
let limit = bounded_limit(params.limit);
let entries = view
.overview()
.iter()
.skip(params.offset)
.take(limit)
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"label": entry.label,
"text": entry.text,
"intervening_entries": entry.intervening_entries,
})
})
.collect::<Vec<_>>();
let has_more = params.offset.saturating_add(entries.len()) < view.overview().len();
json_output(
format!(
"Showing {} Worker session overview entrie(s).",
entries.len()
),
serde_json::json!({
"subject": params.subject,
"entries": entries,
"next_offset": has_more.then_some(params.offset + entries.len()),
}),
)
}
}
struct SearchSessionEntriesTool {
provider: Arc<dyn WorkerObservationProvider>,
}
#[async_trait]
impl Tool for SearchSessionEntriesTool {
async fn execute(
&self,
input_json: &str,
_context: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: SearchSessionEntriesParams = parse_input("SearchSessionEntries", input_json)?;
let view = latest_view(&*self.provider, &params.subject).await?;
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
if let (Some(from), Some(through)) = (&from, &through) {
if from.source_index() > through.source_index() {
return Err(ToolError::InvalidArgument(
"SearchSessionEntries from must not be after through".to_string(),
));
}
}
let entries = view
.search(&SearchOptions {
query: params.query,
kind: params.kind.as_deref().map(parse_kind).transpose()?,
tool_part: params
.tool_part
.as_deref()
.map(parse_tool_part)
.transpose()?,
tool_name: params.tool_name,
limit: Some(bounded_limit(params.limit)),
min_entry_index: None,
from,
through,
offset: params.offset,
})
.into_iter()
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
"tool_name": entry.tool_name,
"label": entry.label,
"text": entry.summary,
})
})
.collect::<Vec<_>>();
json_output(
format!("Found {} Worker session entrie(s).", entries.len()),
serde_json::json!({ "subject": params.subject, "entries": entries }),
)
}
}
struct ReadSessionEntryTool {
provider: Arc<dyn WorkerObservationProvider>,
}
#[async_trait]
impl Tool for ReadSessionEntryTool {
async fn execute(
&self,
input_json: &str,
_context: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: ReadSessionEntryParams = parse_input("ReadSessionEntry", input_json)?;
let entry_ref = parse_entry_ref(&params.entry_ref)?;
let detail = match params.mode.as_str() {
"compact" => ReadDetail::Compact,
"full" => ReadDetail::Full,
other => {
return Err(ToolError::InvalidArgument(format!(
"invalid mode {other:?}; expected compact or full"
)));
}
};
let view = latest_view(&*self.provider, &params.subject).await?;
let read = view.read(
ReadSelector::Id(entry_ref.as_str()),
ReadOptions {
include_tools: true,
tool_part: ToolPart::Both,
detail,
max_items: 1,
max_bytes: MAX_READ_BYTES,
},
);
let entries = read
.entries
.into_iter()
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
"tool_name": entry.tool_name,
"label": entry.label,
"text": entry.text,
})
})
.collect::<Vec<_>>();
if entries.is_empty() {
return Err(ToolError::ExecutionFailed(
"worker session was not found or is not accessible".to_string(),
));
}
json_output(
format!("Read {} Worker session entry.", entries.len()),
serde_json::json!({
"subject": params.subject,
"entries": entries,
"truncated": read.truncated,
}),
)
}
}
async fn latest_view(
provider: &dyn WorkerObservationProvider,
subject: &WorkerObservationSubjectRef,
) -> Result<SessionCapture, ToolError> {
let capture = provider
.capture_worker_session(subject)
.await
.map_err(tool_error)?;
Ok(SessionCapture::new(capture.segment_id, capture.items))
}
fn parse_input<T: serde::de::DeserializeOwned>(
tool_name: &str,
input_json: &str,
) -> Result<T, ToolError> {
serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool_name} input: {error}")))
}
fn parse_entry_ref(value: &str) -> Result<SessionEntryRef, ToolError> {
SessionEntryRef::parse(value)
.ok_or_else(|| ToolError::InvalidArgument(format!("invalid SessionEntryRef {value:?}")))
}
fn parse_kind(value: &str) -> Result<ReferenceKind, ToolError> {
ReferenceKind::parse(value)
.ok_or_else(|| ToolError::InvalidArgument(format!("invalid entry kind {value:?}")))
}
fn parse_tool_part(value: &str) -> Result<ToolPart, ToolError> {
ToolPart::parse(value)
.ok_or_else(|| ToolError::InvalidArgument(format!("invalid tool_part {value:?}")))
}
fn bounded_subject(subject: &WorkerObservationSubjectRef) -> WorkerObservationSubjectRef {
match subject {
WorkerObservationSubjectRef::RuntimeWorker {
runtime_id,
worker_id,
} => WorkerObservationSubjectRef::RuntimeWorker {
runtime_id: truncate_text(runtime_id, 200),
worker_id: truncate_text(worker_id, 200),
},
WorkerObservationSubjectRef::SubWorker { name } => WorkerObservationSubjectRef::SubWorker {
name: truncate_text(name, 200),
},
}
}
fn truncate_text(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
value.to_string()
} else {
let mut truncated = value.chars().take(max_chars).collect::<String>();
truncated.push('…');
truncated
}
}
fn bounded_limit(limit: Option<usize>) -> usize {
limit.unwrap_or(DEFAULT_PAGE_LIMIT).clamp(1, MAX_PAGE_LIMIT)
}
fn tool_error(error: WorkerObservationError) -> ToolError {
match error {
WorkerObservationError::NotFound => ToolError::ExecutionFailed(
"worker session was not found or is not accessible".to_string(),
),
WorkerObservationError::Unavailable(message) => ToolError::ExecutionFailed(message),
}
}
fn json_output(summary: String, value: serde_json::Value) -> Result<ToolOutput, ToolError> {
let content = serde_json::to_string_pretty(&value)
.map_err(|error| ToolError::ExecutionFailed(format!("serialize tool output: {error}")))?;
Ok(ToolOutput {
summary,
content: Some(content),
attachments: Vec::new(),
})
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use llm_engine::Role;
use crate::feature::{FeatureRegistryBuilder, HookRegistryBuilder};
use super::*;
struct FakeProvider {
captures: Mutex<Vec<Item>>,
}
fn granted_subject() -> WorkerObservationSubjectRef {
WorkerObservationSubjectRef::RuntimeWorker {
runtime_id: "runtime-1".to_string(),
worker_id: "granted".to_string(),
}
}
#[async_trait]
impl WorkerObservationProvider for FakeProvider {
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
Ok(vec![WorkerObservationSubject {
subject: granted_subject(),
display_name: "Granted".to_string(),
relation: "peer".to_string(),
status: "idle".to_string(),
}])
}
async fn capture_worker_session(
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError> {
if subject != &granted_subject() {
return Err(WorkerObservationError::NotFound);
}
Ok(WorkerSessionCapture {
segment_id: "segment".to_string(),
items: self.captures.lock().unwrap().clone(),
})
}
}
fn message(_id: &str, role: Role, content: &str) -> Item {
match role {
Role::User => Item::user_message(content),
Role::Assistant => Item::assistant_message(content),
Role::System => Item::system_message(content),
}
}
#[test]
fn prompt_source_names_the_worker_observation_contract() {
for token in [
"ListWorkerSessions",
"ViewSessionOverview",
"SearchSessionEntries",
"ReadSessionEntry",
"SessionEntryRef",
] {
assert!(OBSERVATION_PROMPT_SOURCE.contains(token), "missing {token}");
}
}
#[test]
fn worker_observation_installs_without_session_explore_or_memory_extract() {
let provider = Arc::new(FakeProvider {
captures: Mutex::new(Vec::new()),
});
let mut pending_tools = Vec::new();
let mut hook_builder = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(WorkerObservationFeature::new(provider))
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(report.reports[0].installed);
assert_eq!(
report.installed_tool_names(),
[
"ListWorkerSessions",
"ViewSessionOverview",
"SearchSessionEntries",
"ReadSessionEntry",
]
);
}
#[tokio::test]
async fn provider_grants_hide_unauthorized_subjects_and_latest_capture_preserves_refs() {
let provider = Arc::new(FakeProvider {
captures: Mutex::new(vec![message("u1", Role::User, "first")]),
});
let list = list_definition(provider.clone())().1;
let listed = list
.execute("{}", llm_engine::tool::ToolExecutionContext::direct())
.await
.unwrap();
assert!(listed.content.unwrap().contains("granted"));
let read = read_definition(provider.clone())().1;
let hidden = read
.execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"E00000000"}"#,
llm_engine::tool::ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(format!("{hidden:?}").contains("not found or is not accessible"));
provider
.captures
.lock()
.unwrap()
.push(message("a1", Role::Assistant, "second"));
let output = read
.execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000000"}"#,
llm_engine::tool::ToolExecutionContext::direct(),
)
.await
.unwrap();
assert!(output.content.unwrap().contains("first"));
let output = read
.execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000001"}"#,
llm_engine::tool::ToolExecutionContext::direct(),
)
.await
.unwrap();
assert!(output.content.unwrap().contains("second"));
}
}
+5
View File
@@ -781,6 +781,7 @@ fn render_list_resources_result(result: ListResourcesResult) -> Result<ToolOutpu
Ok(ToolOutput {
summary,
content: Some(content),
attachments: Vec::new(),
})
}
@@ -822,6 +823,7 @@ fn render_read_resource_result(result: ReadResourceResult) -> Result<ToolOutput,
Ok(ToolOutput {
summary,
content: Some(content),
attachments: Vec::new(),
})
}
@@ -876,6 +878,7 @@ fn render_list_prompts_result(result: ListPromptsResult) -> Result<ToolOutput, T
Ok(ToolOutput {
summary,
content: Some(content),
attachments: Vec::new(),
})
}
@@ -924,6 +927,7 @@ fn render_get_prompt_result(result: GetPromptResult) -> Result<ToolOutput, ToolE
Ok(ToolOutput {
summary,
content: Some(content),
attachments: Vec::new(),
})
}
@@ -1242,6 +1246,7 @@ fn render_call_tool_result(
Ok(ToolOutput {
summary,
content: Some(content),
attachments: Vec::new(),
})
}
+7 -1
View File
@@ -4098,6 +4098,8 @@ impl PluginInstance {
Ok(ToolOutput {
summary: format!("{tool_name}: {tool_calls}"),
content: Some(String::from_utf8_lossy(&input).to_string()),
attachments: Vec::new(),
})
}
PluginInstanceRuntime::ComponentInstance(runtime) => {
@@ -5447,7 +5449,11 @@ fn decode_plugin_wasm_output(bytes: &[u8]) -> Result<ToolOutput, PluginWasmError
));
}
};
Ok(ToolOutput { summary, content })
Ok(ToolOutput {
summary,
content,
attachments: Vec::new(),
})
}
fn bounded_message(message: impl Into<String>) -> String {
+12 -2
View File
@@ -419,10 +419,9 @@ pub(crate) async fn spawn_internal_worker_session(
spawn_prepared_internal_worker_session(worker, store, input, None).await
}
pub(crate) async fn spawn_prepared_internal_worker_session(
pub(crate) async fn prepare_internal_worker_session(
mut worker: Worker<Box<dyn LlmClient>, EphemeralSessionStore>,
store: EphemeralSessionStore,
input: String,
on_turn_end: Option<Arc<dyn Fn(InternalWorkerSessionStatus) + Send + Sync>>,
) -> Result<InternalWorkerSessionHandle, InternalWorkerSessionError> {
let session_id = worker.session_id();
@@ -496,6 +495,17 @@ pub(crate) async fn spawn_prepared_internal_worker_session(
}
});
Ok(handle)
}
#[cfg(test)]
pub(crate) async fn spawn_prepared_internal_worker_session(
worker: Worker<Box<dyn LlmClient>, EphemeralSessionStore>,
store: EphemeralSessionStore,
input: String,
on_turn_end: Option<Arc<dyn Fn(InternalWorkerSessionStatus) + Send + Sync>>,
) -> Result<InternalWorkerSessionHandle, InternalWorkerSessionError> {
let handle = prepare_internal_worker_session(worker, store, on_turn_end).await?;
handle.send(input).await?;
Ok(handle)
}
+2 -2
View File
@@ -58,8 +58,8 @@ pub fn fire_and_forget(socket: Option<PathBuf>, event: WorkerEvent) {
/// Only events classified by `WorkerEvent::should_notify_agent` are injected
/// into the parent's LLM context as system messages; control-plane-only events
/// keep this renderer for diagnostics/tests. Agent-visible summaries are kept
/// deliberately short — the LLM can always call `SubWorkerReadOutput` to fetch more
/// detail if the event summary is not enough.
/// deliberately short — the LLM can use worker-observation tools to inspect the committed
/// session when the event summary is not enough.
pub fn render_event(event: &WorkerEvent) -> String {
match event {
WorkerEvent::TurnEnded { worker_name } => {
+4
View File
@@ -328,6 +328,8 @@ impl Interceptor for WorkerInterceptor {
output: ToolOutput {
summary: info.result.summary.clone(),
content: info.result.content.clone(),
attachments: Vec::new(),
},
};
for hook in &self.registry.post_tool_call {
@@ -911,6 +913,8 @@ mod tests {
ToolOutput {
summary: "ok".into(),
content: Some("full".into()),
attachments: Vec::new(),
},
),
meta: info.meta,
@@ -84,6 +84,7 @@ mod tests {
let segments = vec![protocol::Segment::text("hello from log")];
let event = live_log_entry_event(LogEntry::UserInput {
ts: session_store::segment_log::now_millis(),
extensions: vec![],
segments: segments.clone(),
})
.expect("UserInput must be live-relevant");
+5 -5
View File
@@ -11,7 +11,7 @@ pub mod model_client;
pub mod prompt;
pub mod runtime;
pub mod segment_log_sink;
mod session_reference;
mod session_capture;
pub mod shared_state;
mod shutdown_after_idle;
pub mod skill;
@@ -40,9 +40,9 @@ pub use runtime::dir::RuntimeDir;
pub use segment_log_sink::SegmentLogSink;
pub use shared_state::WorkerSharedState;
pub use worker::{
LocalWorkingDirectory, RuntimeWorkspaceHttpClient, Worker, WorkerError,
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient,
WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspaceRequest, WorkspaceRequestMethod,
WorkspaceResponse, apply_worker_manifest, marker_workspace_client,
LocalWorkingDirectory, RuntimeWorkspaceHttpClient, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN,
Worker, WorkerError, WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext,
WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspaceRequest,
WorkspaceRequestMethod, WorkspaceResponse, apply_worker_manifest, marker_workspace_client,
unavailable_workspace_client,
};
+10
View File
@@ -65,6 +65,8 @@ pub enum WorkerPrompt {
MemoryExtractSystem,
/// System prompt of the memory consolidation (integration + tidy) Engine.
MemoryConsolidationSystem,
/// System prompt of the bounded Flow transition verifier.
FlowVerifierSystem,
/// Wrapper around an incoming `Method::Notify` message injected into
/// the next LLM request context as a transient system message.
NotifyWrapper,
@@ -99,6 +101,7 @@ impl WorkerPrompt {
Self::CompactSystem => "compact_system",
Self::MemoryExtractSystem => "memory_extract_system",
Self::MemoryConsolidationSystem => "memory_consolidation_system",
Self::FlowVerifierSystem => "flow_verifier_system",
Self::NotifyWrapper => "notify_wrapper",
Self::InterruptToolResultSummary => "interrupt_tool_result_summary",
Self::InterruptSystemNote => "interrupt_system_note",
@@ -118,6 +121,7 @@ impl WorkerPrompt {
WorkerPrompt::CompactSystem,
WorkerPrompt::MemoryExtractSystem,
WorkerPrompt::MemoryConsolidationSystem,
WorkerPrompt::FlowVerifierSystem,
WorkerPrompt::NotifyWrapper,
WorkerPrompt::InterruptToolResultSummary,
WorkerPrompt::InterruptSystemNote,
@@ -133,6 +137,7 @@ impl WorkerPrompt {
"compact_system",
"memory_extract_system",
"memory_consolidation_system",
"flow_verifier_system",
"notify_wrapper",
"interrupt_tool_result_summary",
"interrupt_system_note",
@@ -342,6 +347,11 @@ impl PromptCatalog {
)
}
/// Render `WorkerPrompt::FlowVerifierSystem` (no inputs).
pub fn flow_verifier_system(&self) -> Result<String, CatalogError> {
self.render(WorkerPrompt::FlowVerifierSystem, Value::UNDEFINED)
}
/// Render `WorkerPrompt::NotifyWrapper` with `{{ message }}`.
pub fn notify_wrapper(&self, message: &str) -> Result<String, CatalogError> {
self.render(WorkerPrompt::NotifyWrapper, single("message", message))
-3
View File
@@ -208,7 +208,6 @@ struct ToolCapabilities {
memory_update_document: bool,
sub_worker_spawn: bool,
sub_worker_send: bool,
sub_worker_read_output: bool,
sub_worker_stop: bool,
sub_worker_list: bool,
sub_worker_restore: bool,
@@ -224,7 +223,6 @@ impl ToolCapabilities {
"MemoryUpdateDocument" => capabilities.memory_update_document = true,
"SubWorkerSpawn" => capabilities.sub_worker_spawn = true,
"SubWorkerSend" => capabilities.sub_worker_send = true,
"SubWorkerReadOutput" => capabilities.sub_worker_read_output = true,
"SubWorkerStop" => capabilities.sub_worker_stop = true,
"SubWorkerList" => capabilities.sub_worker_list = true,
_ => {}
@@ -248,7 +246,6 @@ impl ToolCapabilities {
fn sub_worker_management(self) -> bool {
self.sub_worker_spawn
|| self.sub_worker_send
|| self.sub_worker_read_output
|| self.sub_worker_stop
|| self.sub_worker_list
|| self.sub_worker_restore
+1
View File
@@ -252,6 +252,7 @@ mod tests {
fn user_input(text: &str) -> LogEntry {
LogEntry::UserInput {
ts: now_millis(),
extensions: vec![],
segments: vec![protocol::Segment::Text {
content: text.to_owned(),
}],
@@ -1,26 +1,55 @@
//! Immutable reference view over a session history slice.
//! Workspace- and Memory-independent exploration of an immutable ordered session capture.
//!
//! This module is shared substrate for internal workers that need to inspect a
//! bounded, host-created view of session history without reading the live
//! foreground Worker state directly.
//! Hosts construct a capture from committed session items. The capture excludes reasoning,
//! assigns append-stable `SessionEntryRef` values, and provides sparse overview, bounded
//! range/search, read, and generic evidence projections without granting mutation authority.
use std::sync::Arc;
use llm_engine::{Item, Role};
use memory::extract::StagingEvidence;
use memory::schema::{EvidenceKind, SourceEvidenceRef};
use serde::{Deserialize, Serialize};
const DEFAULT_SEARCH_LIMIT: usize = 20;
const MAX_SEARCH_LIMIT: usize = 50;
const DEFAULT_READ_MAX_ITEMS: usize = 40;
const MAX_READ_MAX_ITEMS: usize = 80;
const DEFAULT_READ_MAX_BYTES: usize = 32 * 1024;
const OVERVIEW_ANCHOR_STRIDE: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub(crate) struct SessionEntryRef(String);
impl SessionEntryRef {
pub(crate) fn new(source_index: usize) -> Self {
Self(format!("E{source_index:08}"))
}
pub(crate) fn parse(value: &str) -> Option<Self> {
let reference = Self(value.to_string());
reference.source_index()?;
Some(reference)
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
pub(crate) fn source_index(&self) -> Option<u64> {
self.0.strip_prefix('E')?.parse().ok()
}
}
impl std::fmt::Display for SessionEntryRef {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReferenceKind {
User,
Assistant,
System,
Tool,
}
@@ -29,7 +58,6 @@ impl ReferenceKind {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
Self::Tool => "tool",
}
}
@@ -38,15 +66,10 @@ impl ReferenceKind {
match value {
"user" => Some(Self::User),
"assistant" | "agent" => Some(Self::Assistant),
"system" => Some(Self::System),
"tool" => Some(Self::Tool),
_ => None,
}
}
fn evidence_kind(self) -> EvidenceKind {
EvidenceKind::new(EvidenceKind::MESSAGE)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -73,16 +96,17 @@ impl ToolPart {
#[derive(Debug, Clone)]
pub(crate) struct OverviewItem {
pub id: String,
pub id: SessionEntryRef,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub label: String,
pub text: String,
pub intervening_entries: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct ReferenceEntry {
pub id: String,
pub id: SessionEntryRef,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
@@ -92,20 +116,6 @@ pub(crate) struct ReferenceEntry {
search_text: String,
}
impl ReferenceEntry {
fn evidence_kind(&self) -> EvidenceKind {
match (self.kind, self.tool_part) {
(ReferenceKind::Tool, Some(ToolPart::Input)) => {
EvidenceKind::new(EvidenceKind::TOOL_CALL)
}
(ReferenceKind::Tool, Some(ToolPart::Output | ToolPart::Both) | None) => {
EvidenceKind::new(EvidenceKind::TOOL_RESULT)
}
_ => self.kind.evidence_kind(),
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct SearchOptions {
pub query: String,
@@ -114,11 +124,14 @@ pub(crate) struct SearchOptions {
pub tool_name: Option<String>,
pub limit: Option<usize>,
pub min_entry_index: Option<u64>,
pub from: Option<SessionEntryRef>,
pub through: Option<SessionEntryRef>,
pub offset: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct SearchHit {
pub id: String,
pub id: SessionEntryRef,
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
@@ -162,7 +175,7 @@ impl Default for ReadOptions {
#[derive(Debug, Clone)]
pub(crate) struct ReadEntry {
pub id: String,
pub id: SessionEntryRef,
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
@@ -178,14 +191,26 @@ pub(crate) struct ReadResult {
}
#[derive(Debug, Clone)]
pub(crate) struct SessionReferenceView {
pub(crate) struct SessionEntryEvidence {
pub segment_id: String,
pub entry_ref: SessionEntryRef,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub label: String,
pub summary: String,
pub excerpt: String,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionCapture {
segment_id: String,
items: Arc<Vec<Item>>,
overview: Vec<OverviewItem>,
index: Vec<ReferenceEntry>,
}
impl SessionReferenceView {
impl SessionCapture {
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
let segment_id = segment_id.into();
let items = Arc::new(items);
@@ -199,7 +224,7 @@ impl SessionReferenceView {
let kind = match role {
Role::User => ReferenceKind::User,
Role::Assistant => ReferenceKind::Assistant,
Role::System => ReferenceKind::System,
Role::System => continue,
};
let text = content
.iter()
@@ -208,7 +233,7 @@ impl SessionReferenceView {
.join("");
let label = format!("{} message", kind.as_str());
let summary = truncate_chars(&text, 240);
let id = format!("M{idx:04}");
let id = SessionEntryRef::new(idx);
index.push(ReferenceEntry {
id: id.clone(),
entry_range,
@@ -221,11 +246,12 @@ impl SessionReferenceView {
});
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
overview.push(OverviewItem {
id: format!("O{:04}", overview.len()),
id: id.clone(),
entry_range,
kind,
label,
text,
intervening_entries: 0,
});
}
}
@@ -234,7 +260,7 @@ impl SessionReferenceView {
} => {
let text = format!("{name}\n{arguments}");
index.push(ReferenceEntry {
id: format!("T{idx:04}i"),
id: SessionEntryRef::new(idx),
entry_range,
kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Input),
@@ -245,11 +271,22 @@ impl SessionReferenceView {
});
}
Item::ToolResult {
summary, content, ..
summary,
content,
attachments,
..
} => {
let text = format!("{summary}\n{}", content.as_deref().unwrap_or_default());
let attachment_marker = if attachments.is_empty() {
String::new()
} else {
format!("\n[{} image attachment(s)]", attachments.len())
};
let text = format!(
"{summary}\n{}{attachment_marker}",
content.as_deref().unwrap_or_default(),
);
index.push(ReferenceEntry {
id: format!("T{idx:04}o"),
id: SessionEntryRef::new(idx),
entry_range,
kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Output),
@@ -263,6 +300,30 @@ impl SessionReferenceView {
}
}
if overview.len() > 2 {
let last = overview.len() - 1;
overview = overview
.into_iter()
.enumerate()
.filter_map(|(index, entry)| {
(index == 0 || index == last || index % OVERVIEW_ANCHOR_STRIDE == 0)
.then_some(entry)
})
.collect();
}
for overview_index in 0..overview.len().saturating_sub(1) {
let current_entry = overview[overview_index].entry_range[0];
let next_entry = overview[overview_index + 1].entry_range[0];
overview[overview_index].intervening_entries = index
.iter()
.filter(|entry| {
let entry_index = entry.entry_range[0];
entry_index > current_entry && entry_index < next_entry
})
.count();
}
Self {
segment_id,
items,
@@ -282,11 +343,21 @@ impl SessionReferenceView {
.unwrap_or(DEFAULT_SEARCH_LIMIT)
.clamp(1, MAX_SEARCH_LIMIT);
let tool_name = options.tool_name.as_deref();
let min_entry_index = options.min_entry_index.unwrap_or(0);
let min_entry_index = options
.from
.as_ref()
.and_then(SessionEntryRef::source_index)
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
let max_entry_index = options
.through
.as_ref()
.and_then(SessionEntryRef::source_index)
.unwrap_or(u64::MAX);
let mut skipped = 0usize;
let mut hits = Vec::new();
for entry in &self.index {
if entry.entry_range[0] < min_entry_index {
if entry.entry_range[0] < min_entry_index || entry.entry_range[0] > max_entry_index {
continue;
}
if let Some(kind) = options.kind {
@@ -313,6 +384,10 @@ impl SessionReferenceView {
if !query.is_empty() && !entry.search_text.to_lowercase().contains(&query) {
continue;
}
if skipped < options.offset {
skipped += 1;
continue;
}
hits.push(SearchHit {
id: entry.id.clone(),
kind: entry.kind,
@@ -338,7 +413,11 @@ impl SessionReferenceView {
let mut truncated = false;
let selected: Vec<&ReferenceEntry> = match selector {
ReadSelector::Id(id) => self.index.iter().filter(|entry| entry.id == id).collect(),
ReadSelector::Id(id) => self
.index
.iter()
.filter(|entry| entry.id.as_str() == id)
.collect(),
ReadSelector::EntryRange([start, end]) => self
.index
.iter()
@@ -384,42 +463,32 @@ impl SessionReferenceView {
ReadResult { entries, truncated }
}
pub(crate) fn source_ref_for(&self, id: &str) -> Option<SourceEvidenceRef> {
let entry = self.index.iter().find(|entry| entry.id == id)?;
Some(SourceEvidenceRef {
segment_id: Some(self.segment_id.clone()),
entry_range: Some(entry.entry_range),
evidence_id: Some(entry.id.clone()),
evidence_kind: Some(entry.evidence_kind()),
label: Some(entry.label.clone()),
summary: Some(entry.summary.clone()),
..Default::default()
})
}
pub(crate) fn staging_evidence_for(&self, id: &str) -> Option<StagingEvidence> {
let entry = self.index.iter().find(|entry| entry.id == id)?;
let read = self.read(
ReadSelector::Id(id),
ReadOptions {
include_tools: true,
tool_part: ToolPart::Both,
detail: ReadDetail::Compact,
max_items: 1,
max_bytes: 2 * 1024,
},
);
let excerpt = read
pub(crate) fn evidence_for(&self, id: &str) -> Option<SessionEntryEvidence> {
let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?;
let excerpt = self
.read(
ReadSelector::Id(id),
ReadOptions {
include_tools: true,
tool_part: ToolPart::Both,
detail: ReadDetail::Compact,
max_items: 1,
max_bytes: 2 * 1024,
},
)
.entries
.first()
.map(|entry| entry.text.clone())
.unwrap_or_else(|| entry.summary.clone());
Some(StagingEvidence {
id: entry.id.clone(),
kind: entry.evidence_kind(),
entry_range: Some(entry.entry_range),
excerpt: Some(excerpt),
summary: Some(entry.summary.clone()),
Some(SessionEntryEvidence {
segment_id: self.segment_id.clone(),
entry_ref: entry.id.clone(),
entry_range: entry.entry_range,
kind: entry.kind,
tool_part: entry.tool_part,
label: entry.label.clone(),
summary: entry.summary.clone(),
excerpt,
})
}
}
@@ -448,21 +517,29 @@ fn render_item(
Item::ToolResult {
summary,
content,
attachments,
is_error,
..
} => match detail {
ReadDetail::Compact => format!(
"[{} ToolOutput{}]\nsummary: {summary}\ncontent: (omitted)",
entry.id,
if *is_error { " error" } else { "" }
),
ReadDetail::Full => format!(
"[{} ToolOutput{}]\nsummary: {summary}\ncontent: {}",
entry.id,
if *is_error { " error" } else { "" },
content.as_deref().unwrap_or_default()
),
},
} => {
let attachment_line = if attachments.is_empty() {
String::new()
} else {
format!("\nattachments: {} image(s)", attachments.len())
};
match detail {
ReadDetail::Compact => format!(
"[{} ToolOutput{}]\nsummary: {summary}\ncontent: (omitted){attachment_line}",
entry.id,
if *is_error { " error" } else { "" },
),
ReadDetail::Full => format!(
"[{} ToolOutput{}]\nsummary: {summary}\ncontent: {}{attachment_line}",
entry.id,
if *is_error { " error" } else { "" },
content.as_deref().unwrap_or_default(),
),
}
}
Item::Reasoning { .. } => format!("[{} Reasoning omitted]", entry.id),
};
truncate_chars(&text, max_bytes)
@@ -486,7 +563,7 @@ mod tests {
#[test]
fn overview_contains_user_and_assistant_only() {
let view = SessionReferenceView::new(
let view = SessionCapture::new(
"segment-1",
vec![
Item::system_message("sys"),
@@ -506,7 +583,7 @@ mod tests {
#[test]
fn search_filters_tool_input_and_output() {
let view = SessionReferenceView::new(
let view = SessionCapture::new(
"segment-1",
vec![
Item::tool_call("c1", "Read", "{\"file\":\"Cargo.toml\"}"),
@@ -521,6 +598,9 @@ mod tests {
tool_name: Some("Read".into()),
limit: None,
min_entry_index: None,
from: None,
through: None,
offset: 0,
});
assert_eq!(input_hits.len(), 1);
assert_eq!(input_hits[0].tool_part, Some(ToolPart::Input));
@@ -532,6 +612,9 @@ mod tests {
tool_name: None,
limit: None,
min_entry_index: None,
from: None,
through: None,
offset: 0,
});
assert_eq!(output_hits.len(), 1);
assert_eq!(output_hits[0].tool_part, Some(ToolPart::Output));
@@ -539,7 +622,7 @@ mod tests {
#[test]
fn read_by_entry_range_is_bounded_and_can_skip_tools() {
let view = SessionReferenceView::new(
let view = SessionCapture::new(
"segment-1",
vec![
Item::user_message("one"),
@@ -570,11 +653,149 @@ mod tests {
}
#[test]
fn source_ref_uses_entry_range_and_evidence_id() {
let view = SessionReferenceView::new("segment-1", vec![Item::user_message("hello")]);
let source = view.source_ref_for("M0000").unwrap();
assert_eq!(source.segment_id.as_deref(), Some("segment-1"));
assert_eq!(source.entry_range, Some([0, 0]));
assert_eq!(source.evidence_id.as_deref(), Some("M0000"));
fn tool_image_projection_exposes_only_bounded_metadata() {
let view = SessionCapture::new(
"segment-1",
vec![Item::tool_result_item_with_attachments(
"c1",
"attached",
None,
false,
vec![llm_engine::tool::Attachment::Image(
llm_engine::tool::ImageAttachment::new(
"image/png",
b"private-image-body".to_vec(),
),
)],
)],
);
let result = view.read(
ReadSelector::Id("E00000000"),
ReadOptions {
include_tools: true,
detail: ReadDetail::Full,
..ReadOptions::default()
},
);
assert_eq!(result.entries.len(), 1);
assert!(result.entries[0].text.contains("attachments: 1 image(s)"));
assert!(!result.entries[0].text.contains("private-image-body"));
assert!(!result.entries[0].text.contains("cHJpdmF0ZS"));
}
#[test]
fn system_prompt_and_reasoning_are_excluded_from_every_projection() {
let view = SessionCapture::new(
"segment-1",
vec![
Item::system_message("raw secret system prompt"),
Item::reasoning("private chain of thought"),
Item::user_message("visible user entry"),
],
);
let hits = view.search(&SearchOptions {
query: String::new(),
kind: None,
tool_part: None,
tool_name: None,
limit: None,
min_entry_index: None,
from: None,
through: None,
offset: 0,
});
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id.as_str(), "E00000002");
assert!(!hits[0].summary.contains("secret"));
assert!(!hits[0].summary.contains("chain of thought"));
assert!(
view.read(ReadSelector::Id("E00000000"), ReadOptions::default())
.entries
.is_empty()
);
assert!(view.evidence_for("E00000000").is_none());
assert_eq!(view.overview().len(), 1);
assert_eq!(view.overview()[0].id.as_str(), "E00000002");
}
#[test]
fn overview_is_sparse_and_reports_intervening_non_reasoning_entries() {
let items = (0..20)
.map(|index| Item::user_message(format!("message-{index}")))
.collect::<Vec<_>>();
let view = SessionCapture::new("segment-1", items);
let refs = view
.overview()
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>();
assert_eq!(
refs,
vec!["E00000000", "E00000008", "E00000016", "E00000019"]
);
assert_eq!(view.overview()[0].intervening_entries, 7);
assert_eq!(view.overview()[1].intervening_entries, 7);
assert_eq!(view.overview()[2].intervening_entries, 2);
}
#[test]
fn append_preserves_existing_session_entry_refs() {
let first = SessionCapture::new(
"segment-1",
vec![
Item::user_message("first"),
Item::assistant_message("second"),
],
);
let appended = SessionCapture::new(
"segment-1",
vec![
Item::user_message("first"),
Item::assistant_message("second"),
Item::user_message("third"),
],
);
let first_refs = first
.search(&SearchOptions {
query: String::new(),
kind: None,
tool_part: None,
tool_name: None,
limit: None,
min_entry_index: None,
from: None,
through: None,
offset: 0,
})
.into_iter()
.map(|entry| entry.id)
.collect::<Vec<_>>();
let appended_refs = appended
.search(&SearchOptions {
query: String::new(),
kind: None,
tool_part: None,
tool_name: None,
limit: None,
min_entry_index: None,
from: None,
through: None,
offset: 0,
})
.into_iter()
.take(2)
.map(|entry| entry.id)
.collect::<Vec<_>>();
assert_eq!(first_refs, appended_refs);
}
#[test]
fn evidence_projection_uses_entry_range_and_session_entry_ref() {
let view = SessionCapture::new("segment-1", vec![Item::user_message("hello")]);
let source = view.evidence_for("E00000000").unwrap();
assert_eq!(source.segment_id, "segment-1");
assert_eq!(source.entry_range, [0, 0]);
assert_eq!(source.entry_ref.as_str(), "E00000000");
}
}
+11
View File
@@ -1,3 +1,4 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{OnceLock, RwLock};
use protocol::WorkerStatus;
@@ -28,6 +29,7 @@ pub struct WorkerSharedState {
/// `ListCompletions` queries without going through the controller. It is
/// unset only in unit tests that construct `WorkerSharedState` directly.
fs_view: OnceLock<WorkerFsView>,
flow_transition_enabled: AtomicBool,
}
impl WorkerSharedState {
@@ -44,6 +46,7 @@ impl WorkerSharedState {
greeting,
status: RwLock::new(WorkerStatus::Idle),
fs_view: OnceLock::new(),
flow_transition_enabled: AtomicBool::new(false),
}
}
@@ -59,6 +62,14 @@ impl WorkerSharedState {
self.fs_view.get()
}
pub fn enable_flow_transition(&self) {
self.flow_transition_enabled.store(true, Ordering::Release);
}
pub fn flow_transition_enabled(&self) -> bool {
self.flow_transition_enabled.load(Ordering::Acquire)
}
pub fn set_status(&self, status: WorkerStatus) {
if let Ok(mut s) = self.status.write() {
*s = status;
+2
View File
@@ -89,6 +89,8 @@ mod tests {
output: ToolOutput {
summary: "result".to_string(),
content: None,
attachments: Vec::new(),
},
}
}
+5 -115
View File
@@ -1,6 +1,6 @@
//! Parent-facing tools for in-process Internal SubWorker sessions.
//!
//! All five tools share the same parent-owned `SpawnedWorkerRegistry` of typed session handles.
//! All four tools share the same parent-owned `SpawnedWorkerRegistry` of typed session handles.
//! There is no Runtime catalog lookup or child socket transport, so a Worker can operate only on
//! its direct Internal children. The socket helper at the bottom remains solely for the legacy
//! top-level Worker callback protocol and is not part of SubWorker communication.
@@ -10,12 +10,10 @@ use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use llm_engine::llm_client::types::{ContentPart, Item, Role};
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use protocol::stream::{JsonLineReader, JsonLineWriter};
use protocol::{Event, Method};
use serde::{Deserialize, Serialize};
use session_store::LogEntry;
use tokio::net::UnixStream;
use crate::spawn::registry::SpawnedWorkerRegistry;
@@ -71,6 +69,7 @@ impl Tool for SubWorkerListTool {
Ok(ToolOutput {
summary: format!("listed {count} child SubWorker(s)"),
content: Some(content),
attachments: Vec::new(),
})
}
}
@@ -96,7 +95,7 @@ pub fn sub_worker_list_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinit
const SEND_TO_POD_DESCRIPTION: &str = "Send a text message to a previously spawned SubWorker. The SubWorker \
processes it as a user turn. Fails if the SubWorker is already executing a \
turn retry after it finishes. Does not wait for the turn to complete; \
use `SubWorkerReadOutput` to fetch results afterwards.";
use worker-observation tools to inspect its committed session.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct SubWorkerSendInput {
@@ -126,6 +125,7 @@ impl Tool for SubWorkerSendTool {
return Ok(ToolOutput {
summary: format!("sent message to `{}`", input.name),
content: None,
attachments: Vec::new(),
});
}
Err(unknown_worker_err(&input.name))
@@ -146,76 +146,6 @@ pub fn sub_worker_send_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinit
})
}
// ---------------------------------------------------------------------------
// SubWorkerReadOutput
// ---------------------------------------------------------------------------
const READ_POD_OUTPUT_DESCRIPTION: &str = "Fetch new assistant text from a SubWorker since the last read. \
Uses an internal cursor per-SubWorker so consecutive calls return only \
newly-produced output. Returns the SubWorker's current status and the new \
text, or reports `stopped` if the SubWorker can no longer be reached.";
struct SubWorkerReadOutputTool {
registry: Arc<SpawnedWorkerRegistry>,
}
#[async_trait]
impl Tool for SubWorkerReadOutputTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let input: NameInput = serde_json::from_str(input_json).map_err(|e| {
ToolError::InvalidArgument(format!("invalid SubWorkerReadOutput input: {e}"))
})?;
if let Some(record) = self.registry.get_internal(&input.name) {
let entries = record.session.entries();
let cursor = self.registry.cursor(&input.name).await;
let new_entries = if cursor >= entries.len() {
&[] as &[LogEntry]
} else {
&entries[cursor..]
};
let values = new_entries
.iter()
.filter_map(|entry| serde_json::to_value(entry).ok())
.collect::<Vec<_>>();
let new_text = extract_assistant_text(&values);
self.registry.set_cursor(&input.name, entries.len()).await;
let status = format!("{:?}", record.session.status()).to_lowercase();
let summary = if new_text.is_empty() {
format!("worker `{}` {status}; no new assistant text", input.name)
} else {
format!(
"worker `{}` {status}: {} new line(s) of assistant text",
input.name,
new_text.lines().count()
)
};
return Ok(ToolOutput {
summary,
content: (!new_text.is_empty()).then_some(new_text),
});
}
Err(unknown_worker_err(&input.name))
}
}
pub fn sub_worker_read_output_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(NameInput);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("SubWorkerReadOutput")
.description(READ_POD_OUTPUT_DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(SubWorkerReadOutputTool {
registry: registry.clone(),
});
(meta, tool)
})
}
// ---------------------------------------------------------------------------
// SubWorkerStop
// ---------------------------------------------------------------------------
@@ -249,6 +179,7 @@ impl Tool for SubWorkerStopTool {
input.name
),
content: None,
attachments: Vec::new(),
});
}
Err(unknown_worker_err(&input.name))
@@ -323,47 +254,6 @@ where
}
}
fn extract_assistant_text(entries: &[serde_json::Value]) -> String {
let mut out = String::new();
for value in entries {
// The wire payload is the JSON form of `session_store::LogEntry`.
// Walk current singular assistant items and the seeded history in
// post-compaction `SegmentStart` entries.
let Ok(entry) = serde_json::from_value::<LogEntry>(value.clone()) else {
continue;
};
match entry {
LogEntry::SegmentStart { history, .. } => {
for logged in history {
push_assistant_text(&mut out, logged);
}
}
LogEntry::AssistantItem { item, .. } => push_assistant_text(&mut out, item),
_ => continue,
}
}
out
}
fn push_assistant_text(out: &mut String, logged: session_store::LoggedItem) {
let item: Item = logged.into();
if let Item::Message {
role: Role::Assistant,
content,
..
} = item
{
for part in content {
if let ContentPart::Text { text } = part {
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(&text);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+131 -53
View File
@@ -1,23 +1,23 @@
//! Parent-owned registry of direct Internal SubWorker sessions.
//!
//! `SubWorkerSpawn` inserts typed `InternalWorkerSessionHandle`s; List/Send/ReadOutput/Stop use
//! the same in-memory authority. Internal children are not persisted, restored, discovered as
//! `SubWorkerSpawn` inserts typed `InternalWorkerSessionHandle`s; List/Send/Stop and
//! worker-observation use the same in-memory authority. Internal children are not persisted, restored, discovered as
//! Runtime Workers, or addressed through sockets. Restore consumes any legacy persisted process
//! child records only to reclaim their delegated scope and clear obsolete metadata.
//!
//! `SubWorkerReadOutput` owns a per-child, process-lifetime history cursor so consecutive reads
//! yield only new assistant text. Parent registry drop closes all session handles and synchronously
//! returns delegated Write deny rules to the parent scope.
//! Parent registry drop closes all session handles and synchronously returns delegated Write deny
//! rules to the parent scope.
use std::collections::HashMap;
use std::collections::HashSet;
use std::io;
use std::sync::Arc;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use manifest::{Permission, ScopeRule, SharedScope};
use session_store::{
WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError,
};
use tokio::sync::Mutex;
use tracing::warn;
use crate::internal_worker::InternalWorkerSessionHandle;
@@ -29,11 +29,69 @@ pub(crate) struct InternalSpawnedWorkerRecord {
pub worker_name: String,
pub scope_delegated: Vec<ScopeRule>,
pub session: InternalWorkerSessionHandle,
scope_reclaimed: Arc<AtomicBool>,
}
impl InternalSpawnedWorkerRecord {
pub(crate) fn new(
worker_name: String,
scope_delegated: Vec<ScopeRule>,
session: InternalWorkerSessionHandle,
) -> Self {
Self {
worker_name,
scope_delegated,
session,
scope_reclaimed: Arc::new(AtomicBool::new(false)),
}
}
fn claim_scope_reclaim(&self) -> bool {
!self.scope_reclaimed.swap(true, Ordering::AcqRel)
}
fn restore_scope_reclaim(&self) {
self.scope_reclaimed.store(false, Ordering::Release);
}
}
pub(crate) struct InternalSpawnReservation {
registry: Arc<SpawnedWorkerRegistry>,
worker_name: String,
committed: bool,
}
impl InternalSpawnReservation {
pub(crate) fn commit(mut self, record: InternalSpawnedWorkerRecord) -> io::Result<()> {
if record.worker_name != self.worker_name {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"internal SubWorker reservation name does not match record name",
));
}
self.registry
.internal_records
.lock()
.map_err(|_| io::Error::other("internal spawned-worker registry lock poisoned"))?
.push(record);
self.committed = true;
Ok(())
}
}
impl Drop for InternalSpawnReservation {
fn drop(&mut self) {
if !self.committed {
if let Ok(mut names) = self.registry.internal_names.lock() {
names.remove(&self.worker_name);
}
}
}
}
pub struct SpawnedWorkerRegistry {
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
cursors: Mutex<HashMap<String, usize>>,
internal_names: std::sync::Mutex<HashSet<String>>,
parent_scope: Option<SharedScope>,
}
@@ -48,7 +106,7 @@ impl SpawnedWorkerRegistry {
pub fn new(_runtime_dir: Arc<RuntimeDir>) -> Arc<Self> {
Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()),
cursors: Mutex::new(HashMap::new()),
internal_names: std::sync::Mutex::new(HashSet::new()),
parent_scope: None,
})
}
@@ -56,7 +114,7 @@ impl SpawnedWorkerRegistry {
pub(crate) fn new_internal(_parent_name: String, parent_scope: SharedScope) -> Arc<Self> {
Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()),
cursors: Mutex::new(HashMap::new()),
internal_names: std::sync::Mutex::new(HashSet::new()),
parent_scope: Some(parent_scope),
})
}
@@ -133,32 +191,33 @@ impl SpawnedWorkerRegistry {
Ok(SpawnedWorkerRegistryLoad {
registry: Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()),
cursors: Mutex::new(HashMap::new()),
internal_names: std::sync::Mutex::new(HashSet::new()),
parent_scope,
}),
reclaimed_unreachable: !persisted_children.is_empty(),
})
}
pub(crate) fn add_internal(&self, record: InternalSpawnedWorkerRecord) -> io::Result<()> {
let mut records = self
.internal_records
pub(crate) fn reserve_internal_name(
self: &Arc<Self>,
worker_name: String,
) -> io::Result<InternalSpawnReservation> {
let mut names = self
.internal_names
.lock()
.map_err(|_| io::Error::other("internal spawned-worker registry lock poisoned"))?;
if records
.iter()
.any(|existing| existing.worker_name == record.worker_name)
{
.map_err(|_| io::Error::other("internal SubWorker name registry lock poisoned"))?;
if !names.insert(worker_name.clone()) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"spawned worker `{}` is already registered",
record.worker_name
),
format!("spawned worker `{worker_name}` is already registered"),
));
}
records.push(record);
Ok(())
drop(names);
Ok(InternalSpawnReservation {
registry: Arc::clone(self),
worker_name,
committed: false,
})
}
pub(crate) fn get_internal(&self, worker_name: &str) -> Option<InternalSpawnedWorkerRecord> {
@@ -177,39 +236,57 @@ impl SpawnedWorkerRegistry {
.unwrap_or_default()
}
pub(crate) fn reclaim_internal_scope(&self, worker_name: &str) -> io::Result<bool> {
let record = self.get_internal(worker_name).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "internal SubWorker not found")
})?;
self.reclaim_record_scope(&record)
}
fn reclaim_record_scope(&self, record: &InternalSpawnedWorkerRecord) -> io::Result<bool> {
if !record.claim_scope_reclaim() {
return Ok(false);
}
let result = if let Some(parent_scope) = &self.parent_scope {
parent_scope
.update(|current| current.with_removed_deny_rules(delegated_write_rules(record)))
.map(|_| true)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))
} else {
Ok(true)
};
if result.is_err() {
record.restore_scope_reclaim();
}
result
}
pub(crate) async fn remove_internal(
&self,
worker_name: &str,
) -> io::Result<Option<InternalSpawnedWorkerRecord>> {
let removed = {
let mut records = self
.internal_records
.lock()
.map_err(|_| io::Error::other("internal spawned-worker registry lock poisoned"))?;
records
.iter()
.position(|record| record.worker_name == worker_name)
.map(|index| records.remove(index))
};
self.cursors.lock().await.remove(worker_name);
if let (Some(record), Some(parent_scope)) = (&removed, &self.parent_scope) {
parent_scope
.update(|current| current.with_removed_deny_rules(delegated_write_rules(record)))
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
if let Some(record) = self.get_internal(worker_name) {
self.reclaim_record_scope(&record)?;
}
let removed =
{
let mut records = self.internal_records.lock().map_err(|_| {
io::Error::other("internal spawned-worker registry lock poisoned")
})?;
let mut names = self.internal_names.lock().map_err(|_| {
io::Error::other("internal SubWorker name registry lock poisoned")
})?;
let removed = records
.iter()
.position(|record| record.worker_name == worker_name)
.map(|index| records.remove(index));
if removed.is_some() {
names.remove(worker_name);
}
removed
};
Ok(removed)
}
pub async fn cursor(&self, worker_name: &str) -> usize {
*self.cursors.lock().await.get(worker_name).unwrap_or(&0)
}
pub async fn set_cursor(&self, worker_name: &str, value: usize) {
self.cursors
.lock()
.await
.insert(worker_name.to_owned(), value);
}
}
impl Drop for SpawnedWorkerRegistry {
@@ -222,6 +299,7 @@ impl Drop for SpawnedWorkerRegistry {
};
let write_rules = records
.iter()
.filter(|record| !record.scope_reclaimed.load(Ordering::Acquire))
.flat_map(delegated_write_rules)
.collect::<Vec<_>>();
let _ = parent_scope.update(|current| current.with_removed_deny_rules(write_rules));
+200 -46
View File
@@ -18,13 +18,17 @@ use manifest::{
WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
};
use serde::Deserialize;
use tokio::sync::mpsc;
use crate::PromptLoader;
use crate::controller::register_worker_tools;
use crate::internal_worker::{EphemeralSessionStore, spawn_prepared_internal_worker_session};
use crate::internal_worker::{
EphemeralSessionStore, InternalWorkerSessionStatus, prepare_internal_worker_session,
};
use crate::prompt::catalog::PromptCatalog;
use crate::spawn::registry::SpawnedWorkerRegistry;
use crate::worker::{Worker, WorkerFilesystemAuthority};
use protocol::Method;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct SubWorkerSpawnInput {
@@ -203,6 +207,39 @@ fn parse_spawn_profile_selector(raw: Option<&str>) -> Result<SpawnProfileSelecto
Ok(SpawnProfileSelector::Registry(ProfileSelector::named(raw)))
}
#[derive(Clone)]
pub(crate) enum ParentNotificationTarget {
Controller(mpsc::WeakSender<Method>),
Buffer(crate::ipc::notify_buffer::NotifyBuffer),
}
impl ParentNotificationTarget {
fn notify(&self, message: String, auto_run: bool) {
match self {
Self::Controller(parent_method_tx) => {
let Some(parent_method_tx) = parent_method_tx.upgrade() else {
tracing::warn!(
"parent Worker controller closed before Internal SubWorker completion notification"
);
return;
};
tokio::spawn(async move {
if let Err(error) = parent_method_tx
.send(Method::Notify { message, auto_run })
.await
{
tracing::warn!(
%error,
"failed to notify parent Worker about Internal SubWorker completion"
);
}
});
}
Self::Buffer(parent_notifies) => parent_notifies.push_notify(message, auto_run),
}
}
}
/// Runtime dependencies the `SubWorkerSpawn` tool needs in order to launch a
/// child SubWorker and record the handoff locally. Constructed by the Worker
/// controller once per Worker lifetime.
@@ -210,7 +247,7 @@ pub struct SubWorkerSpawnTool {
/// Spawner's own Worker name, used for direct-child identity collision checks.
spawner_name: String,
workspace_context: crate::worker::WorkerWorkspaceContext,
parent_notifies: crate::ipc::notify_buffer::NotifyBuffer,
parent_notifications: ParentNotificationTarget,
/// Runtime-owned root used only for bounded Internal Worker tool artifacts such as Bash spill
/// output. It is not an Internal Worker identity or catalog location.
runtime_base: PathBuf,
@@ -254,7 +291,7 @@ impl SubWorkerSpawnTool {
fn new(
spawner_name: String,
workspace_context: crate::worker::WorkerWorkspaceContext,
parent_notifies: crate::ipc::notify_buffer::NotifyBuffer,
parent_notifications: ParentNotificationTarget,
runtime_base: PathBuf,
workspace_root: PathBuf,
spawner_cwd: PathBuf,
@@ -268,7 +305,7 @@ impl SubWorkerSpawnTool {
Self {
spawner_name,
workspace_context,
parent_notifies,
parent_notifications,
runtime_base,
workspace_root,
spawner_cwd,
@@ -303,6 +340,10 @@ impl Tool for SubWorkerSpawnTool {
input.name
)));
}
let name_reservation = self
.registry
.reserve_internal_name(input.name.clone())
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
let scope_allow = parse_scope(&input.scope)?;
self.validate_delegation_scope(&scope_allow)?;
@@ -362,6 +403,7 @@ impl Tool for SubWorkerSpawnTool {
.join("bash-output"),
self.runtime_base.clone(),
child_registry,
None,
)
.await
.map_err(|error| {
@@ -385,16 +427,27 @@ impl Tool for SubWorkerSpawnTool {
}
let child_name = input.name.clone();
let parent_notifies = self.parent_notifies.clone();
let session_result = spawn_prepared_internal_worker_session(
let registry = Arc::downgrade(&self.registry);
let parent_notifications = self.parent_notifications.clone();
let session_result = prepare_internal_worker_session(
child,
store,
input.task.clone(),
Some(Arc::new(move |status| {
parent_notifies.push_notify(
format!("SubWorker `{child_name}` turn ended with status {status:?}. Read its output before making completion decisions."),
false,
if status == InternalWorkerSessionStatus::Failed {
if let Some(registry) = registry.upgrade() {
if let Err(error) = registry.reclaim_internal_scope(&child_name) {
tracing::warn!(
child_name,
%error,
"failed to reclaim delegated scope after Internal SubWorker failure"
);
}
}
}
let message = format!(
"SubWorker `{child_name}` turn ended with status {status:?}. Inspect its committed session with worker-observation tools before making completion decisions."
);
parent_notifications.notify(message, true);
})),
)
.await;
@@ -407,17 +460,17 @@ impl Tool for SubWorkerSpawnTool {
.update(|current| current.with_removed_deny_rules(revoke_write.clone()));
}
return Err(ToolError::ExecutionFailed(format!(
"start Internal Worker session: {error}"
"prepare Internal Worker session: {error}"
)));
}
};
let record = crate::spawn::registry::InternalSpawnedWorkerRecord {
worker_name: input.name.clone(),
scope_delegated: scope_allow,
session: session.clone(),
};
if let Err(error) = self.registry.add_internal(record) {
let record = crate::spawn::registry::InternalSpawnedWorkerRecord::new(
input.name.clone(),
scope_allow,
session.clone(),
);
if let Err(error) = name_reservation.commit(record) {
let _ = session.stop().await;
if !revoke_write.is_empty() {
let _ = self
@@ -428,10 +481,19 @@ impl Tool for SubWorkerSpawnTool {
"register Internal Worker session: {error}"
)));
}
if let Err(error) = session.send(input.task).await {
let _ = session.stop().await;
let _ = self.registry.remove_internal(&input.name).await;
return Err(ToolError::ExecutionFailed(format!(
"start Internal Worker session: {error}"
)));
}
Ok(ToolOutput {
summary: format!("spawned internal worker `{}`", input.name),
content: None,
attachments: Vec::new(),
})
}
}
@@ -740,10 +802,10 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
/// tool-result budget — debugging beyond this should read the file
/// directly.
/// Factory for the `SubWorkerSpawn` tool.
pub fn sub_worker_spawn_tool(
pub(crate) fn sub_worker_spawn_tool(
spawner_name: String,
workspace_context: crate::worker::WorkerWorkspaceContext,
parent_notifies: crate::ipc::notify_buffer::NotifyBuffer,
parent_notifications: ParentNotificationTarget,
runtime_base: PathBuf,
workspace_root: PathBuf,
spawner_cwd: PathBuf,
@@ -755,7 +817,7 @@ pub fn sub_worker_spawn_tool(
sub_worker_spawn_tool_impl(
spawner_name,
workspace_context,
parent_notifies,
parent_notifications,
runtime_base,
workspace_root,
spawner_cwd,
@@ -769,7 +831,7 @@ pub fn sub_worker_spawn_tool(
fn sub_worker_spawn_tool_impl(
spawner_name: String,
workspace_context: crate::worker::WorkerWorkspaceContext,
parent_notifies: crate::ipc::notify_buffer::NotifyBuffer,
parent_notifications: ParentNotificationTarget,
runtime_base: PathBuf,
workspace_root: PathBuf,
spawner_cwd: PathBuf,
@@ -800,7 +862,7 @@ fn sub_worker_spawn_tool_impl(
let tool: Arc<dyn Tool> = Arc::new(SubWorkerSpawnTool::new(
spawner_name.clone(),
workspace_context.clone(),
parent_notifies.clone(),
parent_notifications.clone(),
runtime_base.clone(),
workspace_root.clone(),
spawner_cwd.clone(),
@@ -820,13 +882,16 @@ fn sub_worker_spawn_tool_impl(
mod tests {
use super::*;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Duration;
use crate::WorkspaceId;
use async_trait::async_trait;
use futures::Stream;
use llm_engine::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_engine::llm_client::types::ContentPart;
use llm_engine::llm_client::{ClientError, LlmClient, Request};
use llm_engine::{Item, Role};
use manifest::{AuthRef, ModelManifest, SchemeKind, WorkerManifest};
use tempfile::TempDir;
@@ -870,7 +935,18 @@ extract_threshold = 4000
"#;
#[tokio::test]
async fn reviewer_profile_spawns_as_workspace_aware_internal_session() {
async fn parent_controller_notification_target_does_not_keep_channel_open() {
let (parent_method_tx, mut parent_method_rx) = mpsc::channel(1);
let target = ParentNotificationTarget::Controller(parent_method_tx.downgrade());
drop(parent_method_tx);
assert!(parent_method_rx.recv().await.is_none());
target.notify("late completion".to_string(), true);
}
#[tokio::test]
async fn reviewer_profile_spawns_and_notifies_parent_controller() {
let runtime = TempDir::new().unwrap();
let workspace_root = runtime.path().join("project");
let available_profiles = write_project_profile_registry(
@@ -890,8 +966,9 @@ extract_threshold = 4000
Arc::new(AvailableWorkspaceClient),
);
let calls = Arc::new(AtomicUsize::new(0));
let observed_parent_write_revoked = Arc::new(std::sync::atomic::AtomicBool::new(false));
let observed_instruction_override = Arc::new(std::sync::atomic::AtomicBool::new(false));
let observed_parent_write_revoked = Arc::new(AtomicBool::new(false));
let observed_instruction_override = Arc::new(AtomicBool::new(false));
let fail_requests = Arc::new(AtomicBool::new(false));
let workspace_prompts = runtime.path().join("workspace-prompts");
std::fs::create_dir_all(&workspace_prompts).unwrap();
std::fs::write(
@@ -900,11 +977,11 @@ extract_threshold = 4000
)
.unwrap();
let prompt_loader = PromptLoader::new(None, Some(workspace_prompts));
let parent_notifies = crate::ipc::notify_buffer::NotifyBuffer::new();
let (parent_method_tx, mut parent_method_rx) = mpsc::channel(8);
let tool = SubWorkerSpawnTool::new(
"parent".into(),
workspace_context,
parent_notifies.clone(),
ParentNotificationTarget::Controller(parent_method_tx.downgrade()),
runtime.path().to_path_buf(),
workspace_root.clone(),
workspace_root.clone(),
@@ -921,6 +998,7 @@ extract_threshold = 4000
delegated_path: workspace_root.clone(),
observed_parent_write_revoked: observed_parent_write_revoked.clone(),
observed_instruction_override: observed_instruction_override.clone(),
fail_requests: fail_requests.clone(),
}));
let input = serde_json::json!({
"name": "reviewer-child",
@@ -936,6 +1014,18 @@ extract_threshold = 4000
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
let mut invalid_input = input.clone();
invalid_input["scope"][0]["target"] =
serde_json::json!(runtime.path().join("outside-parent-scope"));
tool.execute(
&serde_json::to_string(&invalid_input).unwrap(),
llm_engine::tool::ToolExecutionContext::direct(),
)
.await
.expect_err("invalid delegation must fail before child preparation");
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
let output = tool
.execute(
&serde_json::to_string(&input).unwrap(),
@@ -955,9 +1045,40 @@ extract_threshold = 4000
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(observed_parent_write_revoked.load(Ordering::SeqCst));
assert!(observed_instruction_override.load(Ordering::SeqCst));
assert_eq!(parent_notifies.len(), 1);
let completion = tokio::time::timeout(Duration::from_secs(1), parent_method_rx.recv())
.await
.expect("SubWorker completion must wake the parent method channel")
.expect("parent method channel remains open");
assert!(matches!(
completion,
Method::Notify {
message,
auto_run: true,
} if message.contains("SubWorker `reviewer-child` turn ended with status Idle")
));
assert!(!runtime.path().join("reviewer-child/sock").exists());
let duplicate_error = tool
.execute(
&serde_json::to_string(&input).unwrap(),
llm_engine::tool::ToolExecutionContext::direct(),
)
.await
.expect_err("duplicate child name must be rejected before a first turn starts");
assert!(
format!("{duplicate_error:?}").contains("already registered"),
"unexpected duplicate error: {duplicate_error:?}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"duplicate rejection must not invoke the child provider"
);
assert!(matches!(
parent_method_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
let context = llm_engine::tool::ToolExecutionContext::direct();
let list = (crate::spawn::comm_tools::sub_worker_list_tool(registry.clone()))().1;
let listed = list.execute("{}", context.clone()).await.unwrap();
@@ -968,22 +1089,23 @@ extract_threshold = 4000
.contains("reviewer-child")
);
let read = (crate::spawn::comm_tools::sub_worker_read_output_tool(registry.clone()))().1;
let first_output = read
.execute(r#"{"name":"reviewer-child"}"#, context.clone())
.await
.unwrap();
assert!(
first_output
.content
.unwrap_or_default()
.contains("reviewed")
);
let second_output = read
.execute(r#"{"name":"reviewer-child"}"#, context.clone())
.await
.unwrap();
assert!(second_output.content.is_none());
let observation =
crate::feature::builtin::worker_observation::SpawnedSubWorkerObservationProvider::new(
registry.clone(),
);
let observed_child =
crate::feature::builtin::worker_observation::WorkerObservationSubjectRef::SubWorker {
name: "reviewer-child".to_string(),
};
let first_capture = crate::feature::builtin::worker_observation::WorkerObservationProvider::capture_worker_session(
&observation,
&observed_child,
)
.await
.unwrap();
assert!(first_capture.items.iter().any(|item| {
matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
}));
let send = (crate::spawn::comm_tools::sub_worker_send_tool(registry.clone()))().1;
send.execute(
@@ -997,6 +1119,31 @@ extract_threshold = 4000
crate::internal_worker::InternalWorkerSessionStatus::Idle
);
assert_eq!(calls.load(Ordering::SeqCst), 2);
let latest_capture = crate::feature::builtin::worker_observation::WorkerObservationProvider::capture_worker_session(
&observation,
&observed_child,
)
.await
.unwrap();
assert!(latest_capture.items.len() > first_capture.items.len());
fail_requests.store(true, Ordering::SeqCst);
send.execute(
r#"{"name":"reviewer-child","message":"trigger terminal failure"}"#,
context.clone(),
)
.await
.unwrap();
assert_eq!(
record.session.wait_until_idle().await,
InternalWorkerSessionStatus::Failed
);
assert_eq!(calls.load(Ordering::SeqCst), 3);
assert!(
spawner_scope.snapshot().is_writable(&workspace_root),
"Failed terminal child must automatically reclaim its delegated write scope"
);
assert!(registry.get_internal("reviewer-child").is_some());
let stop = (crate::spawn::comm_tools::sub_worker_stop_tool(registry.clone()))().1;
stop.execute(r#"{"name":"reviewer-child"}"#, context)
@@ -1005,6 +1152,7 @@ extract_threshold = 4000
assert!(registry.get_internal("reviewer-child").is_none());
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
fail_requests.store(false, Ordering::SeqCst);
let mut teardown_input = input;
teardown_input["name"] = serde_json::json!("reviewer-child-parent-drop");
tool.execute(
@@ -1015,9 +1163,9 @@ extract_threshold = 4000
.unwrap();
assert!(!spawner_scope.snapshot().is_writable(&workspace_root));
drop(list);
drop(read);
drop(send);
drop(stop);
drop(observation);
drop(tool);
drop(registry);
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
@@ -1127,6 +1275,7 @@ extract_threshold = 4000
delegated_path: PathBuf,
observed_parent_write_revoked: Arc<std::sync::atomic::AtomicBool>,
observed_instruction_override: Arc<std::sync::atomic::AtomicBool>,
fail_requests: Arc<AtomicBool>,
}
#[async_trait]
@@ -1155,6 +1304,11 @@ extract_threshold = 4000
.is_some_and(|prompt| prompt.contains("WORKSPACE REVIEWER OVERRIDE")),
Ordering::SeqCst,
);
if self.fail_requests.load(Ordering::SeqCst) {
return Err(ClientError::Config(
"scripted Internal Worker failure".into(),
));
}
Ok(Box::pin(futures::stream::iter(vec![
Ok(LlmEvent::text_block_start(0)),
Ok(LlmEvent::text_delta(0, "reviewed")),
+565 -22
View File
@@ -13,7 +13,8 @@ use llm_engine::llm_client::types::Role;
use llm_engine::state::Mutable;
use llm_engine::{Engine, EngineError, EngineResult, ToolOutputLimits, UsageRecord};
use session_store::{
LogEntry, SegmentId, SessionId, Store, StoreError, SystemItem, segment_log, to_logged,
LogEntry, SegmentId, SessionExtension, SessionId, Store, StoreError, SystemItem, segment_log,
to_logged,
};
use session_store::{
WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, WorkerReclaimedChild,
@@ -32,7 +33,8 @@ use crate::compact::state::CompactState;
use crate::compact::usage_tracker::UsageTracker;
use crate::feature::builtin::memory::WorkspaceMemoryBackendError;
use crate::feature::builtin::{
SessionExploreFeature, SessionExploreState, TaskFeature, render_extract_input,
MemoryExtractFeature, MemoryExtractState, SessionExploreFeature, SessionExploreState,
TaskFeature, WorkerObservationProvider, render_extract_input,
};
use crate::feature::{
FeatureInstructionDeclaration, FeatureInstructionId, FeatureRegistryBuilder,
@@ -79,7 +81,9 @@ use protocol::{
use tokio::net::UnixStream;
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
use workdir::{LocalWorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle};
use workdir::{
LocalWorkdirSession, ReadOnlyWorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle,
};
const RESTORE_RECONCILIATION_REACHABILITY_TIMEOUT: Duration = Duration::from_millis(500);
@@ -544,6 +548,7 @@ struct EmptyTurnRollbackSnapshot {
usage_history_len: usize,
ai_activity_count: usize,
last_run_interrupted: bool,
flow_runtime_state: Option<flow::FlowRuntimeState>,
}
fn is_ai_materialized_item(item: &Item) -> bool {
@@ -623,6 +628,30 @@ where
}
}
struct SessionFlowRuntimeStateCommitter<St: Store + Clone> {
writer: LogWriterHandle<St>,
}
impl<St> crate::feature::builtin::flow_transition::FlowRuntimeStateCommitter
for SessionFlowRuntimeStateCommitter<St>
where
St: Store + Clone + Send + Sync + 'static,
{
fn commit(&self, state: &flow::FlowRuntimeState) -> Result<(), String> {
let payload = serde_json::to_value(state)
.map_err(|error| format!("serialize Flow runtime state: {error}"))?;
self.writer
.append_entry(LogEntry::Extension {
ts: segment_log::now_millis(),
domain: FLOW_RUNTIME_EXTENSION_DOMAIN.to_string(),
payload,
})
.map_err(|error| error.to_string())
}
}
pub const WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN: &str = "worker.input-submission.v1";
/// An independent agent execution unit.
///
/// Holds a [`Engine`] directly and persists session state via
@@ -649,6 +678,10 @@ pub struct Worker<C: LlmClient, St: Store> {
/// Path-free workspace identity/client context injected by Runtime/host.
/// This never grants local filesystem authority.
workspace_context: WorkerWorkspaceContext,
/// Runtime-owned durable Flow state reconstructed from Worker session
/// extensions on restore.
flow_runtime_state: Arc<Mutex<Option<flow::FlowRuntimeState>>>,
flow_feature_enabled: bool,
/// Shared, atomically-swappable view of the Worker's resolved scope.
/// Cloned into local WorkdirSession providers used by builtin tools, fs_view,
/// and compaction so updates propagate at the next permission check.
@@ -686,6 +719,9 @@ pub struct Worker<C: LlmClient, St: Store> {
/// the narrow snapshot/restore surface Worker needs for compaction and rewind.
/// Store/reminder ownership stays inside the Task feature module.
task_feature: TaskFeature,
/// Host-owned projection of Worker sessions explicitly granted to this Worker.
/// The provider reauthorizes every capture and never derives authority from model input.
worker_observation_provider: Option<Arc<dyn WorkerObservationProvider>>,
/// Parsed system-prompt template awaiting first-turn materialisation.
/// `Some` until `ensure_system_prompt_materialized` renders it once,
/// then `None` forever — including after compaction.
@@ -829,6 +865,8 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
filesystem_authority: self.filesystem_authority.clone(),
workdir_session: self.workdir_session.clone(),
workspace_context: self.workspace_context.clone(),
flow_runtime_state: self.flow_runtime_state.clone(),
flow_feature_enabled: self.flow_feature_enabled,
scope: self.scope.clone(),
delegation_scope: self.delegation_scope.clone(),
hook_builder: HookRegistryBuilder::new(),
@@ -839,6 +877,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
usage_history: self.usage_history.clone(),
tracker: None,
task_feature: self.task_feature.clone(),
worker_observation_provider: None,
system_prompt_template: None,
feature_instructions: self.feature_instructions.clone(),
alerter: self.alerter.clone(),
@@ -1026,6 +1065,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
filesystem_authority,
workdir_session,
workspace_context,
flow_runtime_state: Arc::new(Mutex::new(None)),
flow_feature_enabled: false,
scope,
delegation_scope,
hook_builder: HookRegistryBuilder::new(),
@@ -1036,6 +1077,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
tracker: None,
task_feature: TaskFeature::new(),
worker_observation_provider: None,
system_prompt_template: None,
feature_instructions: Vec::new(),
alerter: None,
@@ -1170,6 +1212,19 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
self.workspace_context.client_handle()
}
/// Bind the host-owned Worker-session observation projection. The provider
/// is responsible for workspace authorization and per-capture revalidation.
pub fn bind_worker_observation_provider(
&mut self,
provider: Option<Arc<dyn WorkerObservationProvider>>,
) {
self.worker_observation_provider = provider;
}
pub(crate) fn worker_observation_provider(&self) -> Option<Arc<dyn WorkerObservationProvider>> {
self.worker_observation_provider.clone()
}
async fn resident_summary_from_workspace_authority(
&self,
) -> Result<Option<String>, WorkerError> {
@@ -1298,6 +1353,81 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
report
}
/// Install the Flow transition feature against this Worker's Runtime-owned
/// durable Flow state. Workspace authority is used only when resolving a
/// new immutable source snapshot during Submit.
pub fn install_runtime_flow_transition_feature(
&mut self,
) -> Result<FeatureRegistryInstallReport, String>
where
C: Clone + Send + Sync + 'static,
St: Clone + Send + Sync + 'static,
{
self.flow_feature_enabled = true;
let writer = LogWriterHandle {
store: self.store.clone(),
state: self.segment_state.clone(),
sink: self.sink.clone(),
in_flight: self.in_flight.clone(),
};
let coordinator = Arc::new(
crate::feature::builtin::flow_transition::RuntimeFlowCoordinatorClient::new(
self.flow_runtime_state.clone(),
Arc::new(SessionFlowRuntimeStateCommitter { writer }),
),
);
Ok(self.install_flow_transition_feature(coordinator))
}
/// Install the Flow transition tool for a host-authorized active Flow instance.
///
/// The coordinator is already bound to the authenticated Workspace/Worker;
/// model input cannot select an instance or Worker identity. The verifier uses
/// an immutable capture of the current committed segment and, for local
/// Workdirs, exposes only Read/Glob/Grep through a read-only session.
pub fn install_flow_transition_feature(
&mut self,
coordinator: Arc<dyn crate::feature::builtin::flow_transition::FlowCoordinatorClient>,
) -> FeatureRegistryInstallReport
where
C: Clone + Send + Sync + 'static,
St: Clone + Send + Sync + 'static,
{
let location = self.segment_state.location();
let capture = crate::feature::builtin::flow_transition::StoreFlowParentCapture::new(
self.store.clone(),
location.session_id,
location.segment_id,
);
let read_only_tools = self
.workdir_session
.clone()
.map(|source| Arc::new(ReadOnlyWorkdirSession::new(source)) as WorkdirSessionHandle)
.map(tools::read_only_builtin_tools)
.unwrap_or_default();
let client = self
.engine
.as_ref()
.expect("worker taken during run")
.client()
.clone();
let verifier = Arc::new(
crate::feature::builtin::flow_transition::WorkerBackedFlowVerifier::new(
client,
self.manifest.clone(),
capture,
read_only_tools,
),
);
let state = crate::feature::builtin::flow_transition::FlowTransitionState::new(
coordinator,
verifier,
);
self.install_features(FeatureRegistryBuilder::new().with_module(
crate::feature::builtin::flow_transition::FlowTransitionFeature::new(state),
))
}
/// Reference to the store.
pub fn store(&self) -> &St {
&self.store
@@ -1891,6 +2021,11 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
usage_history_len,
ai_activity_count: self.ai_activity_counter.load(Ordering::SeqCst),
last_run_interrupted: self.engine().last_run_interrupted(),
flow_runtime_state: self
.flow_runtime_state
.lock()
.expect("flow_runtime_state poisoned")
.clone(),
}
}
@@ -1917,6 +2052,10 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
self.engine_mut().truncate_history(snapshot.history_len);
self.engine_mut()
.set_last_run_interrupted(snapshot.last_run_interrupted);
*self
.flow_runtime_state
.lock()
.expect("flow_runtime_state poisoned") = snapshot.flow_runtime_state;
self.user_segments.truncate(snapshot.user_segments_len);
*self
.pending_attachments
@@ -1937,6 +2076,102 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
Ok(())
}
fn prepare_flow_input(
&self,
input: Vec<Segment>,
) -> Result<(Vec<Segment>, Option<flow::FlowRuntimeState>), WorkerError> {
let flow_segments = input
.iter()
.filter_map(|segment| match segment {
Segment::Flow { selector } => Some(selector.as_str()),
_ => None,
})
.collect::<Vec<_>>();
if flow_segments.is_empty() {
return Ok((input, None));
}
if flow_segments.len() != 1 {
return Err(WorkerError::FlowInput(
"one Submit may contain at most one Flow segment".to_string(),
));
}
if !self.flow_feature_enabled {
return Err(WorkerError::FlowInput(
"resolved Profile does not enable feature.flow".to_string(),
));
}
if self
.flow_runtime_state
.lock()
.expect("flow_runtime_state poisoned")
.as_ref()
.is_some_and(|state| state.instance.status == flow::FlowInstanceStatus::Active)
{
return Err(WorkerError::FlowInput(
"Worker already has an active Flow instance".to_string(),
));
}
let selector = flow_segments[0]
.parse::<flow::FlowSelector>()
.map_err(|error| WorkerError::FlowInput(error.to_string()))?;
let workspace = self.workspace_context.client_handle();
if !workspace.is_available() {
return Err(WorkerError::FlowInput(
"Workspace client is unavailable".to_string(),
));
}
let workspace_id = workspace
.workspace_id()
.filter(|workspace_id| !workspace_id.trim().is_empty())
.ok_or_else(|| {
WorkerError::FlowInput("Workspace client has no Workspace scope".to_string())
})?;
let request = flow::FlowSourceResolveRequest {
selector: selector.clone(),
};
let response = workspace
.execute(WorkspaceRequest {
method: WorkspaceRequestMethod::Post,
path: format!("/api/w/{workspace_id}/flows/resolve"),
body: Some(serde_json::to_string(&request).map_err(|error| {
WorkerError::FlowInput(format!("serialize Flow source request: {error}"))
})?),
})
.map_err(|error| WorkerError::FlowInput(error.to_string()))?;
if !(200..300).contains(&response.status) {
let message = serde_json::from_str::<serde_json::Value>(&response.body)
.ok()
.and_then(|body| {
body.get("error")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| "Workspace rejected Flow source resolution".to_string());
return Err(WorkerError::FlowInput(message));
}
let source: flow::ResolvedFlowSource = serde_json::from_str(&response.body)
.map_err(|error| WorkerError::FlowInput(format!("decode Flow source: {error}")))?;
if source.workspace_id != workspace_id || source.selector != selector {
return Err(WorkerError::FlowInput(
"Workspace returned a Flow source outside the requested scope".to_string(),
));
}
let (state, initial_instructions) =
flow::FlowRuntimeState::start(&source, uuid::Uuid::now_v7().to_string())
.map_err(|error| WorkerError::FlowInput(error.to_string()))?;
let input = input
.into_iter()
.map(|segment| match segment {
Segment::Flow { .. } => Segment::Text {
content: initial_instructions.clone(),
},
other => other,
})
.collect();
Ok((input, Some(state)))
}
/// Send user input and run until the LLM turn completes.
///
/// `input` is a typed segment list (see [`protocol::Segment`]). The
@@ -1949,6 +2184,25 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// the Engine is aborted, history is compacted, and execution resumes
/// automatically.
pub async fn run(&mut self, input: Vec<Segment>) -> Result<WorkerRunResult, WorkerError> {
self.run_with_input_extensions(input, Vec::new()).await
}
pub(crate) async fn run_with_input_extensions(
&mut self,
input: Vec<Segment>,
mut input_extensions: Vec<SessionExtension>,
) -> Result<WorkerRunResult, WorkerError> {
let (input, pending_flow_state) = self.prepare_flow_input(input)?;
if let Some(state) = pending_flow_state.as_ref() {
let payload = serde_json::to_value(state).map_err(|error| {
WorkerError::FlowInput(format!("serialize Flow runtime state: {error}"))
})?;
input_extensions.push(SessionExtension::new(
FLOW_RUNTIME_EXTENSION_DOMAIN,
payload,
));
}
// Paused→Run transition: if the previous turn was cut short,
// any `Item::ToolCall` whose tool never produced a matching
// `ToolResult` is closed with a synthetic one, and a short
@@ -1979,7 +2233,14 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
self.commit_entry(LogEntry::UserInput {
ts: segment_log::now_millis(),
segments: input.clone(),
extensions: input_extensions,
})?;
if let Some(state) = pending_flow_state {
*self
.flow_runtime_state
.lock()
.expect("flow_runtime_state poisoned") = Some(state);
}
self.user_segments.push(input.clone());
// Resolve `@<path>` file refs to system messages stashed for the
@@ -2131,6 +2392,15 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
for seg in segments {
match seg {
Segment::Text { .. } | Segment::Paste { .. } | Segment::FileRef { .. } => {}
Segment::Flow { selector } => {
self.alert(
AlertLevel::Error,
AlertSource::Worker,
format!(
"received unresolved Flow invocation {selector:?}; Runtime must resolve Flow segments through Workspace authority before Worker input"
),
);
}
Segment::Unknown => {
self.alert(
AlertLevel::Warn,
@@ -3024,7 +3294,23 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
at_turn_index: source_turn_count,
}),
};
let initial_entries = vec![entry.clone()];
let mut initial_entries = vec![entry.clone()];
if let Some(flow_state) = self
.flow_runtime_state
.lock()
.expect("flow_runtime_state poisoned")
.as_ref()
{
initial_entries.push(LogEntry::Extension {
ts: segment_log::now_millis(),
domain: FLOW_RUNTIME_EXTENSION_DOMAIN.to_string(),
payload: serde_json::to_value(flow_state).map_err(|error| {
WorkerError::InvalidState(format!(
"serialize Flow runtime state during compaction: {error}"
))
})?,
});
}
self.store
.create_segment(old_loc.session_id, new_segment_id, &initial_entries)?;
self.segment_state.set_location(SegmentLocation {
@@ -3033,12 +3319,10 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
});
self.segment_state
.set_entries_written(initial_entries.len());
let session_start = entry;
// Broadcast the SegmentStart through the sink. This atomically
// resets the mirror to the replacement segment prefix so any subscriber
// querying after this point sees the post-compaction prefix, including
// durable extension state.
self.sink.reset_with_initial_entries(vec![session_start]);
// Broadcast the complete compacted prefix. Runtime-owned extensions
// must remain visible and restorable with the replacement segment.
self.sink
.reset_with_initial_entries(initial_entries.clone());
// Keep workers.json pointing at the live segment_id. Without this
// a concurrent `restore_from_manifest(new_segment_id)` would
// see no live writer and grab the session this Worker just moved
@@ -3445,15 +3729,21 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
segment_id: source_segment_id.to_string(),
range: [start_entry as u64, end_entry as u64],
};
let session_view = crate::session_reference::SessionReferenceView::new(
let session_view = crate::session_capture::SessionCapture::new(
source_segment_id.to_string(),
items_to_extract,
);
let session_explore_state =
SessionExploreState::new(session_view, self.workspace_client_handle(), source);
let session_explore_state = SessionExploreState::new(session_view.clone());
let memory_extract_state = MemoryExtractState::new(
session_view,
self.workspace_client_handle(),
source,
audit.run_id.to_string(),
);
let input_text = render_extract_input(session_explore_state.view());
let features = FeatureRegistryBuilder::new()
.with_module(SessionExploreFeature::new(session_explore_state.clone()));
.with_module(SessionExploreFeature::new(session_explore_state.clone()))
.with_module(MemoryExtractFeature::new(memory_extract_state.clone()));
let mut internal_manifest = self.manifest.clone();
internal_manifest.model = model.clone();
let internal_spec = InternalWorkerSpec {
@@ -3469,10 +3759,11 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
max_turns: extract_worker_max_turns,
features,
required_tools: &[
"search_evidence",
"read_evidence",
"stage_candidate",
"finish_extraction",
"ShowOverview",
"SearchEntries",
"ReadEntry",
"StageMemoryCandidate",
"FinishMemoryExtraction",
],
authority: InternalWorkerAuthority {
workspace: self.workspace_context.clone(),
@@ -3533,11 +3824,11 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
}
};
let staging_results = session_explore_state.staged();
if !session_explore_state.is_finished() {
let staging_results = memory_extract_state.staged();
if !memory_extract_state.is_finished() {
tracing::warn!(
staged_count = staging_results.len(),
"extract worker did not call finish_extraction; advancing pointer with staged output"
"extract worker did not call FinishMemoryExtraction; advancing pointer with staged output"
);
}
let staging_id = staging_results.first().cloned().unwrap_or_default();
@@ -3915,6 +4206,8 @@ where
filesystem_authority: common.filesystem_authority,
workdir_session,
workspace_context: common.workspace_context,
flow_runtime_state: Arc::new(Mutex::new(None)),
flow_feature_enabled: false,
scope,
delegation_scope: common.delegation_scope,
hook_builder: HookRegistryBuilder::new(),
@@ -3925,6 +4218,7 @@ where
usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None,
task_feature: TaskFeature::new(),
worker_observation_provider: None,
system_prompt_template: common.system_prompt_template,
feature_instructions: common.feature_instructions,
alerter: None,
@@ -3989,6 +4283,8 @@ where
filesystem_authority: common.filesystem_authority,
workdir_session,
workspace_context: common.workspace_context,
flow_runtime_state: Arc::new(Mutex::new(None)),
flow_feature_enabled: false,
scope,
delegation_scope: common.delegation_scope,
hook_builder: HookRegistryBuilder::new(),
@@ -3999,6 +4295,7 @@ where
usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None,
task_feature: TaskFeature::new(),
worker_observation_provider: None,
system_prompt_template: common.system_prompt_template,
feature_instructions: common.feature_instructions,
alerter: None,
@@ -4097,6 +4394,8 @@ where
filesystem_authority: common.filesystem_authority,
workdir_session,
workspace_context: common.workspace_context,
flow_runtime_state: Arc::new(Mutex::new(None)),
flow_feature_enabled: false,
scope,
delegation_scope: common.delegation_scope,
hook_builder: HookRegistryBuilder::new(),
@@ -4107,6 +4406,7 @@ where
usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None,
task_feature: TaskFeature::new(),
worker_observation_provider: None,
system_prompt_template: common.system_prompt_template,
feature_instructions: common.feature_instructions,
alerter: None,
@@ -4389,6 +4689,10 @@ where
filesystem_authority: common.filesystem_authority,
workdir_session,
workspace_context: common.workspace_context,
flow_runtime_state: Arc::new(Mutex::new(restored_flow_runtime_state(
&state.extensions,
)?)),
flow_feature_enabled: false,
scope,
delegation_scope: common.delegation_scope,
hook_builder: HookRegistryBuilder::new(),
@@ -4399,6 +4703,7 @@ where
usage_history: Arc::new(Mutex::new(state.usage_history)),
tracker: None,
task_feature,
worker_observation_provider: None,
// Restore replays the saved system_prompt verbatim — no
// template re-render on resume.
system_prompt_template: None,
@@ -4866,7 +5171,7 @@ fn build_rewind_targets(segment_id: uuid::Uuid, entries: &[LogEntry]) -> Vec<Rew
let mut turn_index = 0usize;
let mut targets = Vec::new();
for (entry_index, entry) in entries.iter().enumerate() {
if let LogEntry::UserInput { segments, ts } = entry {
if let LogEntry::UserInput { segments, ts, .. } = entry {
turn_index += 1;
let truncate_entries = rewind_truncate_entries(entries, entry_index);
let tool_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
@@ -4930,6 +5235,11 @@ fn preview_segments(segments: &[Segment]) -> String {
preview.push('@');
preview.push_str(path);
}
Segment::Flow { selector } => {
preview.push_str("[Flow: ");
preview.push_str(selector);
preview.push(']');
}
Segment::Unknown => preview.push_str("[unknown input segment]"),
}
}
@@ -4942,8 +5252,31 @@ fn preview_segments(segments: &[Segment]) -> String {
out
}
const FLOW_RUNTIME_EXTENSION_DOMAIN: &str = "flow.runtime.v1";
fn restored_flow_runtime_state(
extensions: &[(String, serde_json::Value)],
) -> Result<Option<flow::FlowRuntimeState>, WorkerError> {
extensions
.iter()
.rev()
.find(|(domain, _)| domain == FLOW_RUNTIME_EXTENSION_DOMAIN)
.map(|(_, payload)| {
serde_json::from_value(payload.clone()).map_err(|error| {
WorkerError::InvalidState(format!("invalid persisted Flow runtime state: {error}"))
})
})
.transpose()
}
#[derive(Debug, thiserror::Error)]
pub enum WorkerError {
#[error("invalid durable Worker state: {0}")]
InvalidState(String),
#[error("Flow input rejected: {0}")]
FlowInput(String),
#[error(transparent)]
Engine(#[from] EngineError),
@@ -5862,6 +6195,64 @@ mod build_summary_prompt_tests {
}
}
#[derive(Debug, Default)]
struct FlowSourceWorkspaceClient {
requests: Mutex<Vec<WorkspaceRequest>>,
}
impl WorkspaceClient for FlowSourceWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace-test")
}
fn kind(&self) -> &str {
"flow-source-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests
.lock()
.expect("Flow source request lock")
.push(request);
let definition = flow::compile_flow_source(
r#"{
schema_version = 1;
name = "coder-review";
initial = "implement";
states = {
implement = {
instructions = "Implement the Ticket and request review.";
transitions = {
done = { target = "done"; condition = "The work is approved."; };
};
};
done = { instructions = "Complete."; terminal = true; };
};
}"#,
)
.unwrap();
let source = flow::ResolvedFlowSource {
selector: "builtin:coder-review".parse().unwrap(),
workspace_id: "workspace-test".to_string(),
flow_id: "flow-source-1".to_string(),
revision: 3,
content_digest: definition.content_digest.clone(),
definition,
};
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(&source).unwrap(),
})
}
}
#[derive(Clone)]
struct NoopClient;
@@ -5897,6 +6288,154 @@ mod build_summary_prompt_tests {
}
}
#[tokio::test]
async fn flow_transition_feature_installs_runtime_local_coordinator() {
let dir = tempfile::tempdir().unwrap();
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
let workspace_client = Arc::new(RecordingAuditWorkspaceClient::default());
let mut worker = Worker::new(
minimal_manifest(),
Engine::new(NoopClient),
store,
WorkerWorkspaceContext::with_client(
Some(WorkspaceId::new("workspace-test").unwrap()),
workspace_client,
),
WorkerFilesystemAuthority::None,
Scope::empty(),
)
.await
.unwrap();
worker.ensure_segment_head().unwrap();
let report = worker
.install_runtime_flow_transition_feature()
.expect("scoped Workspace Flow feature");
assert_eq!(report.reports.len(), 1);
assert!(report.reports[0].installed);
assert_eq!(
report.reports[0]
.installed_tools
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
["RequestFlowTransition"]
);
}
#[tokio::test]
async fn flow_submit_persists_runtime_state_atomically_with_worker_input() {
let dir = tempfile::tempdir().unwrap();
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
let workspace_client = Arc::new(FlowSourceWorkspaceClient::default());
let mut worker = Worker::new(
minimal_manifest(),
Engine::new(NoopClient),
store.clone(),
WorkerWorkspaceContext::with_client(
Some(WorkspaceId::new("workspace-test").unwrap()),
workspace_client.clone(),
),
WorkerFilesystemAuthority::None,
Scope::empty(),
)
.await
.unwrap();
worker.ensure_segment_head().unwrap();
let disabled = worker.prepare_flow_input(vec![Segment::Flow {
selector: "builtin:coder-review".to_string(),
}]);
assert!(
matches!(disabled, Err(WorkerError::FlowInput(message)) if message.contains("feature.flow"))
);
worker.install_runtime_flow_transition_feature().unwrap();
let multiple = worker.prepare_flow_input(vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::Flow {
selector: "workspace:coder-review".to_string(),
},
]);
assert!(
matches!(multiple, Err(WorkerError::FlowInput(message)) if message.contains("at most one"))
);
let invalid = worker.prepare_flow_input(vec![Segment::Flow {
selector: "coder-review".to_string(),
}]);
assert!(matches!(invalid, Err(WorkerError::FlowInput(_))));
let (segments, state) = worker
.prepare_flow_input(vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::text("Implement Ticket 00001"),
])
.unwrap();
let state = state.expect("new Flow runtime state");
let extension = SessionExtension::new(
FLOW_RUNTIME_EXTENSION_DOMAIN,
serde_json::to_value(&state).unwrap(),
);
worker
.commit_entry(LogEntry::UserInput {
ts: segment_log::now_millis(),
segments: segments.clone(),
extensions: vec![extension],
})
.unwrap();
*worker
.flow_runtime_state
.lock()
.expect("flow runtime state lock") = Some(state.clone());
assert!(matches!(segments[0], Segment::Text { .. }));
assert_eq!(segments[1], Segment::text("Implement Ticket 00001"));
assert_eq!(state.instance.definition_revision, 3);
assert_eq!(state.instance.current_state.as_str(), "implement");
assert_eq!(workspace_client.requests.lock().unwrap().len(), 1);
let location = worker.segment_state.location();
let restored = session_store::collect_state(
&store
.read_all(location.session_id, location.segment_id)
.unwrap(),
);
let restored_flow = restored_flow_runtime_state(&restored.extensions)
.unwrap()
.expect("restored Flow state");
assert_eq!(restored_flow, state);
assert_eq!(restored.user_segments, vec![segments]);
let duplicate = worker.prepare_flow_input(vec![Segment::Flow {
selector: "builtin:coder-review".to_string(),
}]);
assert!(
matches!(duplicate, Err(WorkerError::FlowInput(message)) if message.contains("active Flow"))
);
let mut detached = Worker::new(
minimal_manifest(),
Engine::new(NoopClient),
session_store::FsStore::new(dir.path().join("detached-sessions")).unwrap(),
WorkerWorkspaceContext::unavailable(
Some(WorkspaceId::new("workspace-test").unwrap()),
"test unavailable",
),
WorkerFilesystemAuthority::None,
Scope::empty(),
)
.await
.unwrap();
detached.install_runtime_flow_transition_feature().unwrap();
let unavailable = detached.prepare_flow_input(vec![Segment::Flow {
selector: "builtin:coder-review".to_string(),
}]);
assert!(
matches!(unavailable, Err(WorkerError::FlowInput(message)) if message.contains("unavailable"))
);
}
async fn rewind_test_worker() -> (
tempfile::TempDir,
Worker<NoopClient, session_store::FsStore>,
@@ -5942,6 +6481,7 @@ mod build_summary_prompt_tests {
worker,
LogEntry::UserInput {
ts: ts + 1,
extensions: vec![],
segments: vec![text_segment(text)],
},
);
@@ -5967,6 +6507,7 @@ mod build_summary_prompt_tests {
call_id: "call-1".into(),
summary: "wrote a file".into(),
content: None,
attachments: Vec::new(),
is_error: false,
},
},
@@ -6008,6 +6549,7 @@ mod build_summary_prompt_tests {
call_id: "call-1".into(),
summary: "wrote a file".into(),
content: None,
attachments: Vec::new(),
is_error: false,
},
},
@@ -6573,6 +7115,7 @@ mod build_summary_prompt_tests {
worker
.commit_entry(LogEntry::UserInput {
ts: segment_log::now_millis(),
extensions: vec![],
segments: vec![text_segment(
"The cancellation regression must leave this evidence available for retry.",
)],
+8 -7
View File
@@ -310,6 +310,7 @@ permission = "write"
&LogEntry::UserInput {
ts: 9999,
segments: vec![protocol::Segment::text("interloper")],
extensions: vec![],
},
)
.unwrap();
@@ -539,14 +540,14 @@ target = "./"
permission = "write"
"#;
fn finish_extraction_tool_use_events(call_id: &str) -> Vec<LlmEvent> {
fn finish_memory_extraction_tool_use_events(call_id: &str) -> Vec<LlmEvent> {
let input = serde_json::json!({
"staged_count": 0,
"no_candidates_reason": "test run has no durable candidates"
})
.to_string();
vec![
LlmEvent::tool_use_start(0, call_id, "finish_extraction"),
LlmEvent::tool_use_start(0, call_id, "FinishMemoryExtraction"),
LlmEvent::tool_input_delta(0, input),
LlmEvent::tool_use_stop(0),
LlmEvent::Status(StatusEvent {
@@ -559,13 +560,13 @@ fn finish_extraction_tool_use_events(call_id: &str) -> Vec<LlmEvent> {
async fn compact_resets_extract_pointer_so_extract_can_fire_again() {
// Mock LLM responses, in call order:
// [0] first run with usage(1000) so extract threshold (=1) fires.
// [1] extract worker invokes finish_extraction with empty output.
// [1] extract worker invokes FinishMemoryExtraction with empty output.
// [2] extract worker closes after the tool result.
// [3] compact worker invokes write_summary.
// [4] compact worker closes after the tool result.
let client = MockClient::new(vec![
text_events_with_usage("hi", 1000),
finish_extraction_tool_use_events("ec1"),
finish_memory_extraction_tool_use_events("ec1"),
single_text_events("done"),
write_summary_tool_use_events("sc1", "summary"),
single_text_events("done"),
@@ -701,7 +702,7 @@ permission = "write"
async fn extract_large_unprocessed_range_does_not_abort_on_input_occupancy() {
let client = MockClient::new(vec![
text_events_with_usage("recorded", 1000),
finish_extraction_tool_use_events("ec-large"),
finish_memory_extraction_tool_use_events("ec-large"),
single_text_events("done"),
]);
let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
@@ -722,7 +723,7 @@ async fn extract_large_unprocessed_range_does_not_abort_on_input_occupancy() {
async fn spawn_and_wait_drives_extract_to_completion() {
let client = MockClient::new(vec![
text_events_with_usage("hi", 1000),
finish_extraction_tool_use_events("ec1"),
finish_memory_extraction_tool_use_events("ec1"),
single_text_events("done"),
]);
let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
@@ -750,7 +751,7 @@ async fn detached_extract_does_not_fork_session_log() {
// `ensure_head_or_fork` does not spawn a new session.
let client = MockClient::new(vec![
text_events_with_usage("hi", 1000),
finish_extraction_tool_use_events("ec1"),
finish_memory_extraction_tool_use_events("ec1"),
single_text_events("done"),
text_events_with_usage("ok", 1000),
]);
+1
View File
@@ -19,6 +19,7 @@ async-trait.workspace = true
axum = { workspace = true, features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
futures.workspace = true
flow = { path = "../flow" }
manifest.workspace = true
protocol = { workspace = true }
project-record.workspace = true
+189 -35
View File
@@ -1,6 +1,7 @@
use crate::Error;
use crate::resource_broker::{BackendResourceBroker, BackendResourceTarget};
use chrono::Utc;
use protocol::Segment;
use reqwest::blocking::{Client as BlockingHttpClient, RequestBuilder};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use reqwest::{Client as AsyncHttpClient, StatusCode, Url};
@@ -221,6 +222,8 @@ pub struct HostSummary {
pub struct WorkerWorkspaceSummary {
pub visibility: String,
pub identity: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -357,8 +360,8 @@ pub struct WorkerSpawnRequest {
pub profile: ProfileSelector,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ticket_assignment: Option<WorkerTicketAssignmentRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_input: Option<EmbeddedWorkerInput>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub initial_submit: Vec<Segment>,
/// Optional safe working-directory creation request. The Workspace server resolves
/// this into a runtime-internal `WorkingDirectoryRequest` from configured
/// repositories before calling a host.
@@ -372,6 +375,12 @@ pub struct WorkerSpawnRequest {
pub resolved_config_bundle: Option<ConfigBundle>,
#[serde(skip, default)]
pub resolved_workspace_api: Option<WorkspaceApiRef>,
/// Backend-owned feature enablement; client input cannot set it.
#[serde(skip, default)]
pub resolved_worker_observation_enabled: bool,
/// Backend-authored peer-session grants. Browser/model input cannot set this field.
#[serde(skip, default)]
pub resolved_worker_observation_grants: Vec<worker_runtime::identity::RuntimeWorkerRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -402,6 +411,18 @@ pub enum WorkerSpawnAcceptanceRequirement {
RunAccepted { expected_segments: usize },
}
fn initial_worker_input(segments: &[Segment]) -> Option<EmbeddedWorkerInput> {
if segments.is_empty() {
return None;
}
Some(EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::User,
content: Segment::flatten_to_text(segments),
submission_id: None,
segments: Some(segments.to_vec()),
})
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerSpawnResult {
pub state: WorkerOperationState,
@@ -1133,6 +1154,30 @@ impl RuntimeRegistry {
request: WorkerSpawnRequest,
) -> Result<WorkerSpawnResult, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?;
match request.acceptance {
WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments }
if expected_segments != request.initial_submit.len() =>
{
return Err(RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "worker_initial_segment_count_mismatch".to_string(),
message: format!(
"spawn acceptance expects {expected_segments} initial segment(s), request carries {}",
request.initial_submit.len()
),
});
}
WorkerSpawnAcceptanceRequirement::SocketReady if !request.initial_submit.is_empty() => {
return Err(RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "worker_initial_submit_require_run_acceptance".to_string(),
message:
"spawn requests with initial segments must require RunAccepted acceptance"
.to_string(),
});
}
_ => {}
}
let runtime = self.runtime(runtime_id)?;
Ok(runtime.spawn_worker(request))
}
@@ -1489,6 +1534,7 @@ impl EmbeddedWorkerRuntime {
workspace: WorkerWorkspaceSummary {
visibility: "backend_internal".to_string(),
identity: "runtime_registry_worker".to_string(),
workspace_id: summary.workspace_id.clone(),
},
state: embedded_worker_status_label(summary.status).to_string(),
last_seen_at: None,
@@ -1527,6 +1573,7 @@ impl EmbeddedWorkerRuntime {
workspace: WorkerWorkspaceSummary {
visibility: "backend_internal".to_string(),
identity: "runtime_registry_worker".to_string(),
workspace_id: detail.workspace_id.clone(),
},
state: embedded_worker_status_label(detail.status).to_string(),
last_seen_at: None,
@@ -1855,9 +1902,11 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
display_name: request.requested_worker_name.clone(),
config_bundle: None,
profile_source,
initial_input: request.initial_input.clone(),
initial_input: initial_worker_input(&request.initial_submit),
working_directory_request: request.resolved_working_directory_request.clone(),
working_directory: request.resolved_working_directory.clone(),
worker_observation_enabled: request.resolved_worker_observation_enabled,
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
workspace_api: Some(workspace_api),
};
let workspace_scope = RuntimeWorkspaceScope::new(workspace_id, "embedded-backend");
@@ -2133,6 +2182,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
},
content: request.content,
submission_id: None,
segments: request.segments,
};
match self.runtime.send_input(&worker_ref, input) {
@@ -2567,6 +2617,7 @@ impl RemoteWorkerRuntime {
workspace: WorkerWorkspaceSummary {
visibility: "remote_runtime".to_string(),
identity: "runtime_registry_worker".to_string(),
workspace_id: summary.workspace_id.clone(),
},
state: embedded_worker_status_label(summary.status).to_string(),
last_seen_at: None,
@@ -2609,6 +2660,7 @@ impl RemoteWorkerRuntime {
workspace: WorkerWorkspaceSummary {
visibility: "remote_runtime".to_string(),
identity: "runtime_registry_worker".to_string(),
workspace_id: detail.workspace_id.clone(),
},
state: embedded_worker_status_label(detail.status).to_string(),
last_seen_at: None,
@@ -2956,9 +3008,11 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
display_name: request.requested_worker_name.clone(),
config_bundle: None,
profile_source,
initial_input: request.initial_input.clone(),
initial_input: initial_worker_input(&request.initial_submit),
working_directory_request: request.resolved_working_directory_request.clone(),
working_directory: request.resolved_working_directory.clone(),
worker_observation_enabled: request.resolved_worker_observation_enabled,
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
workspace_api: Some(workspace_api),
};
match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) {
@@ -3096,6 +3150,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
},
content: request.content,
submission_id: None,
segments: request.segments,
};
match self.post_json::<_, RuntimeHttpWorkerInputResponse>(
@@ -3547,6 +3602,59 @@ fn embedded_workdir_unsupported_diagnostic() -> RuntimeDiagnostic {
)
}
fn sanitize_embedded_execution_message(
message: &str,
operation: &impl std::fmt::Debug,
outcome: &impl std::fmt::Debug,
) -> String {
let summary =
format!("Embedded Worker execution backend rejected {operation:?} with {outcome:?}");
let mut redact_next = false;
let detail = message
.split_whitespace()
.map(|part| {
if redact_next {
redact_next = false;
return "[redacted]";
}
let lowercase = part.to_ascii_lowercase();
let label =
lowercase.trim_matches(|character: char| !character.is_ascii_alphanumeric());
if matches!(
label,
"bearer" | "credential" | "key" | "password" | "secret" | "session" | "token"
) {
redact_next = true;
}
if part.contains('/')
|| part.contains('\\')
|| lowercase.contains("credential=")
|| lowercase.contains("key=")
|| lowercase.contains("password=")
|| lowercase.contains("secret=")
|| lowercase.contains("session=")
|| lowercase.contains("session_id=")
|| lowercase.contains("token=")
{
"[redacted]"
} else {
part
}
})
.collect::<Vec<_>>()
.join(" ");
let detail = detail.trim();
if detail.is_empty() {
return summary;
}
let truncated = detail.chars().count() > 512;
let mut detail = detail.chars().take(512).collect::<String>();
if truncated {
detail.push('…');
}
format!("{summary}: {detail}")
}
fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
match error {
EmbeddedRuntimeError::RuntimeStopped => diagnostic(
@@ -3566,11 +3674,14 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
"Embedded Worker has no execution backend attached".to_string(),
),
EmbeddedRuntimeError::WorkerExecutionRejected {
operation, outcome, ..
operation,
outcome,
message,
..
} => diagnostic(
"embedded_worker_execution_rejected",
DiagnosticSeverity::Warning,
format!("Embedded Worker execution backend rejected {operation:?} with {outcome:?}"),
sanitize_embedded_execution_message(message, operation, outcome),
),
EmbeddedRuntimeError::LimitTooLarge { requested, max } => diagnostic(
"embedded_runtime_limit_too_large",
@@ -3875,6 +3986,7 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
workspace: WorkerWorkspaceSummary {
visibility: "none".to_string(),
identity: "unsupported".to_string(),
workspace_id: None,
},
state: "unsupported".to_string(),
last_seen_at: None,
@@ -4207,7 +4319,7 @@ mod tests {
worker_runtime::execution::WorkerExecutionSpawnResult::Errored(
worker_runtime::execution::WorkerExecutionResult::errored(
worker_runtime::execution::WorkerExecutionOperation::Spawn,
"provider setup failed at /tmp/secret-provider-config",
"provider setup failed at /tmp/secret-provider-config token=secret-value session_id=session-42",
),
)
}
@@ -4273,6 +4385,7 @@ mod tests {
"missing test context",
);
};
let submission_id = input.submission_id.clone();
let content = input.content;
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(10));
@@ -4289,10 +4402,18 @@ mod tests {
status: protocol::WorkerStatus::Idle,
});
});
worker_runtime::execution::WorkerExecutionResult::accepted(
worker_runtime::execution::WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
)
if let Some(submission_id) = submission_id {
worker_runtime::execution::WorkerExecutionResult::accepted_input_committed(
worker_runtime::execution::WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
submission_id,
)
} else {
worker_runtime::execution::WorkerExecutionResult::accepted(
worker_runtime::execution::WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
)
}
}
}
@@ -4319,6 +4440,7 @@ mod tests {
workspace: WorkerWorkspaceSummary {
visibility: "opaque".to_string(),
identity: host_id.to_string(),
workspace_id: None,
},
state: "available".to_string(),
last_seen_at: None,
@@ -4533,11 +4655,13 @@ mod tests {
},
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: None,
initial_input: None,
initial_submit: Vec::new(),
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: Some(test_workspace_api()),
}
}
@@ -4575,35 +4699,59 @@ mod tests {
assert!(spawned.acceptance_evidence.is_empty());
assert!(spawned.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "embedded_worker_execution_rejected"
&& diagnostic
.message
.contains("provider setup failed at [redacted]")
&& !diagnostic.message.contains("/tmp/secret-provider-config")
&& !diagnostic.message.contains("secret-value")
&& !diagnostic.message.contains("session-42")
}));
assert!(spawned.worker.is_none());
}
#[test]
fn embedded_runtime_rejects_system_initial_input_without_worker_projection() {
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
"local:test",
Arc::new(AcceptingExecutionBackend::default()),
)
.expect("test backend should connect");
fn worker_spawn_idempotency_fingerprint_covers_canonical_initial_submit() {
let mut request = embedded_spawn_request();
request.initial_input = Some(EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::Notify,
content: "system/role instruction belongs in profile".to_string(),
segments: None,
request.ticket_assignment = Some(WorkerTicketAssignmentRequest {
ticket_id: "00001KVZSGT0Q".to_string(),
operation_id: "operation-1".to_string(),
});
request.initial_submit = vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::text("Implement Ticket 00001KVZSGT0Q"),
];
request.acceptance = WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: request.initial_submit.len(),
};
let spawned = runtime.spawn_worker(request);
assert_eq!(spawned.state, WorkerOperationState::Rejected);
assert!(spawned.worker.is_none());
assert!(spawned.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "embedded_worker_initial_input_kind_invalid"
&& diagnostic
.message
.contains("initial worker input must be user input")
}));
assert!(runtime.list_workers(10).items.is_empty());
let first = worker_spawn_idempotency(&request).unwrap().unwrap();
let repeated = worker_spawn_idempotency(&request).unwrap().unwrap();
assert_eq!(first, repeated);
assert_eq!(first.0, "operation-1");
let mut changed = request.clone();
changed.initial_submit[1] = Segment::text("Different instruction");
let changed = worker_spawn_idempotency(&changed).unwrap().unwrap();
assert_ne!(first.1, changed.1);
}
#[test]
fn shared_spawn_projects_typed_initial_submit_to_runtime_user_input() {
let segments = vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::text("Implement Ticket 00001"),
];
let input = initial_worker_input(&segments).expect("typed initial input");
assert_eq!(input.kind, EmbeddedWorkerInputKind::User);
assert_eq!(input.content, Segment::flatten_to_text(&segments));
assert_eq!(input.segments, Some(segments));
assert!(initial_worker_input(&[]).is_none());
}
#[test]
@@ -4682,11 +4830,13 @@ mod tests {
},
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: None,
initial_input: None,
initial_submit: Vec::new(),
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: Some(test_workspace_api()),
},
)
@@ -4777,11 +4927,13 @@ mod tests {
},
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: None,
initial_input: None,
initial_submit: Vec::new(),
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: Some(test_workspace_api()),
},
)
@@ -4811,11 +4963,13 @@ mod tests {
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
ticket_assignment: None,
initial_input: None,
initial_submit: Vec::new(),
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: Some(test_workspace_api()),
},
)
+2
View File
@@ -57,6 +57,8 @@ pub enum Error {
Ticket(#[from] ticket::TicketError),
#[error("yaml error: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("invalid project record id `{0}`")]
InvalidRecordId(String),
#[error("workspace backend config error: {0}")]
@@ -29,12 +29,20 @@ impl WorkerExecutionBackend for TestExecutionBackend {
fn dispatch_input(
&self,
_handle: &WorkerExecutionHandle,
_input: worker_runtime::interaction::WorkerInput,
input: worker_runtime::interaction::WorkerInput,
) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
)
if let Some(submission_id) = input.submission_id {
WorkerExecutionResult::accepted_input_committed(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
submission_id,
)
} else {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
)
}
}
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
@@ -74,6 +82,8 @@ fn create_request(name: &str) -> CreateWorkerRequest {
initial_input: None,
working_directory_request: None,
working_directory: None,
worker_observation_enabled: false,
worker_observation_grants: Vec::new(),
workspace_api: None,
}
}
File diff suppressed because it is too large Load Diff
+515 -7
View File
@@ -3,8 +3,10 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use flow::{CompiledFlowDefinition, FlowSourceKind, compile_flow_source};
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use worker_runtime::identity::RuntimeWorkerRef;
@@ -139,6 +141,16 @@ const MIGRATIONS: &[Migration] = &[
name: "create Worker Workdir attachment reservations",
apply: create_worker_workdir_attachment_reservations,
},
Migration {
version: 25,
name: "create Flow source authority",
apply: create_flow_source_authority,
},
Migration {
version: 26,
name: "remove Backend-owned Flow runtime authority",
apply: remove_backend_flow_runtime_authority,
},
];
struct Migration {
@@ -408,6 +420,31 @@ pub struct MemoryStagingResolutionRecord {
pub resolved_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FlowSourceRecord {
pub workspace_id: String,
pub flow_id: String,
pub source_kind: FlowSourceKind,
pub name: String,
pub path: String,
pub content: String,
pub content_digest: String,
pub revision: u64,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FlowSourceRevisionRecord {
pub workspace_id: String,
pub flow_id: String,
pub revision: u64,
pub content: String,
pub content_digest: String,
pub definition: CompiledFlowDefinition,
pub created_at: String,
}
#[async_trait]
pub trait ControlPlaneStore: Send + Sync {
async fn schema_version(&self) -> Result<i64>;
@@ -417,6 +454,33 @@ pub trait ControlPlaneStore: Send + Sync {
fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()>;
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>>;
fn put_flow_source_for_kind(
&self,
workspace_id: &str,
source_kind: FlowSourceKind,
path: &str,
content: &str,
now: &str,
) -> Result<FlowSourceRecord>;
fn get_flow_source_by_name(
&self,
workspace_id: &str,
source_kind: FlowSourceKind,
name: &str,
) -> Result<Option<FlowSourceRecord>>;
fn list_flow_sources(&self, workspace_id: &str) -> Result<Vec<FlowSourceRecord>>;
fn get_flow_source(
&self,
workspace_id: &str,
flow_id: &str,
) -> Result<Option<FlowSourceRecord>>;
fn get_flow_source_revision(
&self,
workspace_id: &str,
flow_id: &str,
revision: u64,
) -> Result<Option<FlowSourceRevisionRecord>>;
fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>;
fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result<Vec<ObjectiveRecord>>;
fn get_objective(
@@ -866,6 +930,219 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn put_flow_source_for_kind(
&self,
workspace_id: &str,
source_kind: FlowSourceKind,
path: &str,
content: &str,
now: &str,
) -> Result<FlowSourceRecord> {
if source_kind != FlowSourceKind::Workspace {
return Err(Error::Store(
"built-in Flow sources are resource authority and cannot be written to Workspace DB"
.to_string(),
));
}
let name = flow_source_name(path)?;
let definition = compile_flow_source(content).map_err(|error| {
Error::Store(format!(
"compile Flow source {path:?}: {:?}",
error.diagnostics
))
})?;
if definition.name != name {
return Err(Error::Store(format!(
"Flow source name {:?} does not match path slug {name:?}",
definition.name
)));
}
self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
let existing = tx
.query_row(
r#"SELECT workspace_id, flow_id, source_kind, name, path, content,
content_digest, revision, created_at, updated_at
FROM flow_sources
WHERE workspace_id = ?1 AND source_kind = ?2 AND name = ?3"#,
params![workspace_id, source_kind.as_str(), name],
read_flow_source_record,
)
.optional()?;
if let Some(existing) = existing {
if existing.content_digest == definition.content_digest {
tx.commit()?;
return Ok(existing);
}
let revision = existing
.revision
.checked_add(1)
.ok_or_else(|| Error::Store("Flow source revision overflowed".to_string()))?;
let definition_json = serde_json::to_string(&definition)
.map_err(|error| Error::Store(error.to_string()))?;
tx.execute(
r#"INSERT INTO flow_source_revisions (
workspace_id, flow_id, revision, content, content_digest,
definition_json, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
params![
workspace_id,
existing.flow_id,
revision,
content,
definition.content_digest,
definition_json,
now
],
)?;
tx.execute(
r#"UPDATE flow_sources
SET path = ?4, content = ?5, content_digest = ?6,
revision = ?7, updated_at = ?8
WHERE workspace_id = ?1 AND source_kind = ?2 AND name = ?3"#,
params![
workspace_id,
source_kind.as_str(),
name,
path,
content,
definition.content_digest,
revision,
now
],
)?;
tx.commit()?;
return Ok(FlowSourceRecord {
revision,
path: path.to_string(),
content: content.to_string(),
content_digest: definition.content_digest,
updated_at: now.to_string(),
..existing
});
}
let flow_id = Uuid::now_v7().to_string();
let definition_json = serde_json::to_string(&definition)
.map_err(|error| Error::Store(error.to_string()))?;
tx.execute(
r#"INSERT INTO flow_sources (
workspace_id, flow_id, source_kind, name, path, content,
content_digest, revision, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1, ?8, ?8)"#,
params![
workspace_id,
flow_id,
source_kind.as_str(),
name,
path,
content,
definition.content_digest,
now
],
)?;
tx.execute(
r#"INSERT INTO flow_source_revisions (
workspace_id, flow_id, revision, content, content_digest,
definition_json, created_at
) VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6)"#,
params![
workspace_id,
flow_id,
content,
definition.content_digest,
definition_json,
now
],
)?;
tx.commit()?;
Ok(FlowSourceRecord {
workspace_id: workspace_id.to_string(),
flow_id,
source_kind,
name,
path: path.to_string(),
content: content.to_string(),
content_digest: definition.content_digest,
revision: 1,
created_at: now.to_string(),
updated_at: now.to_string(),
})
})
}
fn get_flow_source_by_name(
&self,
workspace_id: &str,
source_kind: FlowSourceKind,
name: &str,
) -> Result<Option<FlowSourceRecord>> {
self.with_conn(|conn| {
conn.query_row(
r#"SELECT workspace_id, flow_id, source_kind, name, path, content,
content_digest, revision, created_at, updated_at
FROM flow_sources
WHERE workspace_id = ?1 AND source_kind = ?2 AND name = ?3"#,
params![workspace_id, source_kind.as_str(), name],
read_flow_source_record,
)
.optional()
.map_err(Error::from)
})
}
fn list_flow_sources(&self, workspace_id: &str) -> Result<Vec<FlowSourceRecord>> {
self.with_conn(|conn| {
let mut statement = conn.prepare(
r#"SELECT workspace_id, flow_id, source_kind, name, path, content,
content_digest, revision, created_at, updated_at
FROM flow_sources WHERE workspace_id = ?1
ORDER BY source_kind ASC, name ASC"#,
)?;
let rows = statement.query_map(params![workspace_id], read_flow_source_record)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn get_flow_source(
&self,
workspace_id: &str,
flow_id: &str,
) -> Result<Option<FlowSourceRecord>> {
self.with_conn(|conn| {
conn.query_row(
r#"SELECT workspace_id, flow_id, source_kind, name, path, content,
content_digest, revision, created_at, updated_at
FROM flow_sources WHERE workspace_id = ?1 AND flow_id = ?2"#,
params![workspace_id, flow_id],
read_flow_source_record,
)
.optional()
.map_err(Error::from)
})
}
fn get_flow_source_revision(
&self,
workspace_id: &str,
flow_id: &str,
revision: u64,
) -> Result<Option<FlowSourceRevisionRecord>> {
self.with_conn(|conn| {
conn.query_row(
r#"SELECT workspace_id, flow_id, revision, content, content_digest,
definition_json, created_at
FROM flow_source_revisions
WHERE workspace_id = ?1 AND flow_id = ?2 AND revision = ?3"#,
params![workspace_id, flow_id, revision],
read_flow_source_revision_record,
)
.optional()
.map_err(Error::from)
})
}
fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()> {
self.with_conn(|conn| {
conn.execute(
@@ -2660,6 +2937,79 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
}
fn flow_source_name(path: &str) -> Result<String> {
let file_name = path
.rsplit('/')
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| Error::Store("Flow source path has no file name".to_string()))?;
let name = file_name
.strip_suffix(".dcdl")
.filter(|value| !value.is_empty())
.ok_or_else(|| Error::Store("Flow source path must end in .dcdl".to_string()))?;
flow::FlowSelector::builtin(name)
.map_err(|error| Error::Store(format!("invalid Flow source slug: {error}")))?;
Ok(name.to_string())
}
fn read_flow_source_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<FlowSourceRecord> {
let source_kind = match row.get::<_, String>(2)?.as_str() {
"builtin" => FlowSourceKind::Builtin,
"workspace" => FlowSourceKind::Workspace,
other => {
return Err(rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Text,
format!("invalid Flow source kind {other:?}").into(),
));
}
};
let revision = row.get::<_, i64>(7)?;
Ok(FlowSourceRecord {
workspace_id: row.get(0)?,
flow_id: row.get(1)?,
source_kind,
name: row.get(3)?,
path: row.get(4)?,
content: row.get(5)?,
content_digest: row.get(6)?,
revision: u64::try_from(revision).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(
7,
rusqlite::types::Type::Integer,
Box::new(error),
)
})?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
})
}
fn read_flow_source_revision_record(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<FlowSourceRevisionRecord> {
let revision = row.get::<_, i64>(2)?;
let definition_json = row.get::<_, String>(5)?;
let definition = serde_json::from_str(&definition_json).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(error))
})?;
Ok(FlowSourceRevisionRecord {
workspace_id: row.get(0)?,
flow_id: row.get(1)?,
revision: u64::try_from(revision).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Integer,
Box::new(error),
)
})?,
content: row.get(3)?,
content_digest: row.get(4)?,
definition,
created_at: row.get(6)?,
})
}
fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceRecord> {
Ok(WorkspaceRecord {
workspace_id: row.get(0)?,
@@ -3623,6 +3973,52 @@ CREATE TABLE IF NOT EXISTS __yoi_schema_migrations (
Ok(())
}
fn create_flow_source_authority(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
CREATE TABLE flow_sources (
workspace_id TEXT NOT NULL,
flow_id TEXT NOT NULL,
source_kind TEXT NOT NULL CHECK (source_kind IN ('builtin', 'workspace')),
name TEXT NOT NULL,
path TEXT NOT NULL,
content TEXT NOT NULL,
content_digest TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, flow_id),
UNIQUE (workspace_id, source_kind, name),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE flow_source_revisions (
workspace_id TEXT NOT NULL,
flow_id TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision > 0),
content TEXT NOT NULL,
content_digest TEXT NOT NULL,
definition_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, flow_id, revision),
FOREIGN KEY (workspace_id, flow_id)
REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE
);
"#,
)?;
Ok(())
}
fn remove_backend_flow_runtime_authority(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
DROP TABLE IF EXISTS flow_events;
DROP TABLE IF EXISTS flow_transition_attempts;
DROP TABLE IF EXISTS flow_instances;
"#,
)?;
Ok(())
}
fn current_schema_version(conn: &Connection) -> Result<i64> {
conn.query_row(
"SELECT COALESCE(MAX(version), 0) FROM __yoi_schema_migrations",
@@ -4182,17 +4578,54 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 24);
assert_eq!(current_schema_version(&conn).unwrap(), 26);
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
}
#[test]
fn schema_v26_removes_legacy_backend_flow_runtime_tables() {
let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap();
for migration in MIGRATIONS
.iter()
.filter(|migration| migration.version <= 25)
{
let tx = conn.unchecked_transaction().unwrap();
(migration.apply)(&tx).unwrap();
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![migration.version, migration.name],
)
.unwrap();
tx.commit().unwrap();
}
conn.execute_batch(
r#"
CREATE TABLE flow_instances (instance_id TEXT PRIMARY KEY);
CREATE TABLE flow_transition_attempts (attempt_id TEXT PRIMARY KEY);
CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
"#,
)
.unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 25);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 26);
assert!(table_exists(&conn, "flow_sources").unwrap());
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
assert!(!table_exists(&conn, "flow_instances").unwrap());
assert!(!table_exists(&conn, "flow_transition_attempts").unwrap());
assert!(!table_exists(&conn, "flow_events").unwrap());
}
#[tokio::test]
async fn migrates_sqlite_and_preserves_workspace_record() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 24);
assert_eq!(store.schema_version().await.unwrap(), 26);
assert!(
!store
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
@@ -4209,13 +4642,88 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT);
store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 24);
assert_eq!(reopened.schema_version().await.unwrap(), 26);
assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(),
Some(record)
);
}
#[tokio::test]
async fn workspace_flow_sources_keep_revisions_and_builtins_stay_resources() {
let dir = tempfile::tempdir().unwrap();
let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-a".to_string(),
owner_account_id: None,
display_name: "Workspace A".to_string(),
state: "active".to_string(),
created_at: "2026-08-06T00:00:00Z".to_string(),
updated_at: "2026-08-06T00:00:00Z".to_string(),
})
.await
.unwrap();
let workspace_source = r#"{
schema_version = 1;
name = "coder-review";
initial = "work";
states = {
work = {
instructions = "Workspace revision one.";
transitions = { done = { target = "done"; condition = "Done."; }; };
};
done = { instructions = ""; terminal = true; };
};
}"#;
let workspace = store
.put_flow_source_for_kind(
"workspace-a",
FlowSourceKind::Workspace,
"flows/coder-review.dcdl",
workspace_source,
"2026-08-06T00:00:01Z",
)
.unwrap();
let builtin = flow::builtin_flow_source("coder-review").unwrap();
assert_eq!(builtin.slug, workspace.name);
assert!(
store
.put_flow_source_for_kind(
"workspace-a",
FlowSourceKind::Builtin,
builtin.path,
builtin.content,
"2026-08-06T00:00:02Z",
)
.is_err()
);
assert_eq!(
store.list_flow_sources("workspace-a").unwrap(),
vec![workspace.clone()]
);
let revision_two =
workspace_source.replace("Workspace revision one.", "Workspace revision two.");
let updated = store
.put_flow_source_for_kind(
"workspace-a",
FlowSourceKind::Workspace,
"flows/coder-review.dcdl",
&revision_two,
"2026-08-06T00:00:03Z",
)
.unwrap();
assert_eq!(updated.flow_id, workspace.flow_id);
assert_eq!(updated.revision, 2);
let pinned = store
.get_flow_source_revision("workspace-a", &workspace.flow_id, 1)
.unwrap()
.unwrap();
assert_eq!(pinned.content, workspace_source);
assert_eq!(pinned.definition.name, "coder-review");
}
#[tokio::test]
async fn ticket_worker_assignment_replaces_current_and_preserves_audit_history() {
let dir = tempfile::tempdir().unwrap();
@@ -4681,7 +5189,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT);
.unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 24);
assert_eq!(store.schema_version().await.unwrap(), 26);
store
.with_conn(|conn| {
@@ -4870,7 +5378,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test]
async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 24);
assert_eq!(store.schema_version().await.unwrap(), 26);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: None,
@@ -4908,7 +5416,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test]
async fn memory_authority_records_round_trip_and_close_staging() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 24);
assert_eq!(store.schema_version().await.unwrap(), 26);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: None,
@@ -5156,7 +5664,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test]
async fn account_and_login_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 24);
assert_eq!(store.schema_version().await.unwrap(), 26);
let now = "2026-07-22T00:00:00Z".to_string();
let account = AccountRecord {
account_id: "acct-user-alice".to_string(),
+13 -11
View File
@@ -9,17 +9,19 @@ It is not a dumping ground for external research, old plans, API inventories, or
1. [`design/overview.md`](design/overview.md) — the system map.
2. [`design/context-history.md`](design/context-history.md) — the highest-risk invariant: inputs that affect the model must be committed to history before they enter context.
3. [`design/worker-session-state.md`](design/worker-session-state.md) — Worker identity, replayable session logs, current metadata, and live process hints.
4. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources.
5. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
6. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries.
7. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins.
8. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
9. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
10. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
11. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks.
12. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
13. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them.
14. [`development/validation.md`](development/validation.md) — how to check changes.
4. [`design/session-observation.md`](design/session-observation.md) — common session captures, `SessionEntryRef`, Memory evidence, and host-authorized Worker observation.
5. [`design/flow-state-graph.md`](design/flow-state-graph.md) — Workspace Flow sources, immutable revisions, transition attempts, and bounded internal verification.
6. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources.
7. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
8. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries.
9. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins.
10. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
11. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
12. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
13. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks.
14. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
15. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them.
16. [`development/validation.md`](development/validation.md) — how to check changes.
## What belongs here
+101
View File
@@ -0,0 +1,101 @@
# Flow state graph and verifier
Flow is a Workspace-scoped declarative state graph. A Worker operating under an active Flow can request evaluation of its current state's outgoing conditions, but cannot select or write a target state directly.
## Source authority
A Workspace-authored Flow source is one DCDL document stored under a virtual path such as `flows/coder-review.dcdl`. Built-in Flow sources are compiled resources under `resources/flows/*.dcdl` and use read-only virtual paths such as `builtin/flows/coder-review.dcdl`. The source document is the graph authority; states and transitions are not normalized into independently editable relational records.
Every invocation uses a source-qualified typed selector:
```text
builtin:<slug>
workspace:<slug>
```
Unqualified selectors and implicit override precedence are rejected. `builtin:coder-review` resolves from the embedded resource catalog. `workspace:coder-review` resolves from the current Workspace DB source. Built-in and Workspace sources with the same slug coexist as distinct logical records.
The compiler pipeline is:
```text
DCDL source
-> decodal evaluation
-> private Serde-compatible value
-> typed Flow source schema
-> graph validation
-> CompiledFlowDefinition
```
The Serde-compatible intermediate is private compiler infrastructure. Public APIs and persisted Flow runtime records use typed Flow domain values.
`flow_sources` stores one current logical Workspace-authored source per Workspace/slug. Every changed source creates an immutable `flow_source_revisions` row containing the original content, content digest, and compiled definition. Built-ins remain read-only embedded resources with an explicit monotonic resource revision; resolving one compiles and returns that resource snapshot without writing it into Workspace DB. Runtime pins source identity, revision, digest, and compiled definition in Worker state, so editing a Workspace source or updating a built-in resource never changes an existing instance.
The compiler rejects unknown fields, unsupported schema versions, invalid/reserved identifiers, unknown transition targets, authored `$cancelled` state/targets, terminal states with outgoing transitions, non-terminal states without transitions, unreachable states, and reachable closed paths that cannot reach a user-declared terminal state. It injects the synthetic exceptional-cancellation transition and `$cancelled` terminal state after validation.
## Runtime-owned instance and event authority
Flow source authority and Flow execution authority are split at the immutable source snapshot boundary.
The Workspace Backend stores only current Flow sources and immutable source revisions. Resolving a source-qualified selector returns the Workspace id, Flow id, revision, digest, and compiled definition. Resolution is read-only with respect to Flow execution: it never creates an instance, attempt, or event.
One Runtime Worker durably owns:
- the pinned source snapshot and compiled definition;
- its active Flow instance, current state, revision, and lifecycle status;
- its active transition attempt;
- its ordered Flow events.
The complete `FlowRuntimeState` is persisted as a typed `flow.runtime.v1` Worker session extension. Initial Flow state is committed in the same `UserInput` log record as the entered-state instructions and remaining Submit segments. Backend therefore cannot contain an active instance that Worker history has never observed.
Transition mutations clone the current Runtime state, append ordered events, persist the new session extension, and only then replace the in-memory projection. A persisted verifying attempt survives Runtime restart and same-Worker restore; the next transition request recovers it instead of creating a competing attempt.
Worker stop retains the Flow with the Worker session. Restoring the same Worker reconstructs the latest state from session extensions and the saved Profile still determines `feature.flow` eligibility. Worker deletion removes the owning Worker/session; Flow state is not implicitly handed to another Worker.
Workspace Server schema migration v26 removes the legacy `flow_instances`, `flow_transition_attempts`, and `flow_events` tables. Backend/Web visibility, when needed, is a bounded Runtime Worker projection rather than a second instance authority.
## Worker boundary
Flow invocation uses the normal Submit/Run segment vector rather than a Worker-create field:
```json
{
"method": "run",
"input": [
{ "kind": "flow", "selector": "builtin:coder-review" },
{ "kind": "text", "content": "Ticket 00001... implementation" }
]
}
```
Runtime accepts exactly one Flow segment only when the resolved Profile enables `feature.flow` and a Workspace client is available. The Worker asks Workspace authority only for an immutable source snapshot, creates the instance locally, replaces the Flow segment with the entered state's instructions, and commits that runtime state atomically with the remaining Submit segments before LLM execution. A Worker with an active Flow rejects the duplicate input without changing its local state or events.
The generic model-facing `WorkerSpawn` accepts `initial_submit: Vec<Segment>` and routes them unchanged through the shared Workspace spawn request into Runtime `CreateWorkerRequest.initial_input`. It does not have a parallel `initial_text` or a role-specific `SpawnCoder` wrapper. Backend derives the flat content projection from the canonical segment vector, validates Flow shape before spawn, and includes the segment vector in lifecycle idempotency fingerprints. Runtime does not commit Worker creation or report spawn success merely because the initial Run method entered the Worker's in-memory channel: Runtime assigns the Submit an opaque id, the Worker commits that id as an extension on the same `UserInput` entry as any initial `FlowRuntimeState`, and the execution backend must return a matching typed input-commit acknowledgement. Restoring the same Worker never replays spawn initial segments.
When an Orchestrator supplies `ticket_id` to generic `WorkerSpawn`, the Worker tool derives the assignment operation id from the durable tool-call id rather than accepting lifecycle authority from model input. The shared Workspace worker-create route projects that request into a Coder Ticket-role intent and atomically applies the existing queued-Ticket assignment operation only after Runtime has returned the input-commit acknowledgement. A spawn or pre-commit input failure therefore leaves the Ticket queued and unassigned.
`RequestFlowTransition` accepts only:
```json
{ "reason": "bounded explanation" }
```
It does not accept Workspace, Flow, instance, Runtime, Worker, state, transition, or target identifiers. `RuntimeFlowCoordinatorClient` reads and persists only the current Worker's local `FlowRuntimeState`; no Workspace mutation client participates in transitions. Profile enablement makes the capability eligible but does not create or select an instance; without an active instance the transition request fails closed.
The transition tool begins or recovers a locally persisted attempt, runs one internal verifier, and resolves the typed verifier outcome into the Worker-owned state. The resulting state and next-state instructions are committed in the normal tool result history.
## Internal verifier authority
The internal verifier receives:
- one immutable snapshot of committed parent-session entries through `session-explore`;
- the captured current state, request reason, and complete outgoing condition list;
- `FinishFlowVerification`;
- when the parent has a Workdir session, only `Read`, `Glob`, and `Grep` backed by a `ReadOnlyWorkdirSession` capability-reducing wrapper.
It does not receive Workspace, Ticket, Memory, Worker-management, write/edit, Bash, or command authority. The read-only wrapper reports only read capabilities, rejects mutation/command operations, and closing it does not close the parent's source session.
`FinishFlowVerification` accepts exactly one `met | not_met | indeterminate` result and a bounded rationale for every transition id in the attempt. Missing, unknown, or duplicate ids are rejected before a result is recorded. A prose-only internal Worker completion is a failed verifier outcome, not a successful transition.
## Separation from the role-owned loop
This state graph does not implement Coder/Reviewer lifecycle, state-entry side effects, deterministic condition providers, or arbitrary Flow state data. The downstream role-owned loop submits the built-in Coder Flow segment, follows entered-state instructions, and uses Flow events plus typed review/repository evidence for its higher-level completion decisions.
+47
View File
@@ -0,0 +1,47 @@
# Session capture and Worker observation
Yoi uses one session-entry exploration domain for host-provided snapshots, Memory extraction evidence, and authorized observation of active Workers. The domain is implemented in `worker::session_capture` and has no Workspace or Memory mutation authority.
## Common capture contract
A host constructs an immutable ordered `SessionCapture` from committed session items. The capture:
- excludes reasoning before overview, search, read, or evidence projection;
- does not include the session system-prompt field;
- assigns an append-stable `SessionEntryRef` (`E...`) from the committed source position;
- uses the same reference in sparse overview anchors, search results, bounded reads, and Memory evidence conversion;
- pages sparse real user/assistant anchors and reports the number of non-reasoning entries between anchors;
- supports bounded range search and compact range listing when no filter is supplied;
- bounds read item count and bytes.
`SessionEntryRef` is local to the selected session subject. Runtime peers use the canonical `{ kind: "runtime_worker", runtime_id, worker_id }` reference; parent-owned children use `{ kind: "sub_worker", name }`. A model must first select a host-projected session and must reuse both the structured subject and entry references returned for that session.
## Independent features
The three feature modules share only the capture domain:
- `session-explore` installs `ShowOverview`, `SearchEntries`, and `ReadEntry` for one immutable host snapshot. It has no Workspace client or Memory state.
- `memory-extract` installs `StageMemoryCandidate` and `FinishMemoryExtraction`. It validates every staged `entry_ref` against its co-installed capture before converting it to typed Memory evidence.
- `worker-observation` installs `ListWorkerSessions`, `ViewSessionOverview`, `SearchSessionEntries`, and `ReadSessionEntry`. It captures the selected Worker again on every operation, so newly committed entries become visible while existing append-only references remain stable.
The features do not enable or mutate each other. Feature-registry collision checks remain authoritative for tool names.
## Observation authority
`WorkerObservationProvider` is a host-injected authority boundary. The Worker receives only an `Arc<dyn WorkerObservationProvider>`; model input never supplies grants, Workspace credentials, Runtime URLs, session handles, repository paths, or provider clients.
The provider must:
1. list only active subjects already granted to the current Worker;
2. reauthorize every capture instead of trusting a previous list result;
3. return the same not-found result for missing and unauthorized subjects;
4. return only committed session items;
5. keep subject identifiers opaque and bounded.
Runtime/Backend integrations enable the feature through the Backend-only `WorkerSpawnRequest.resolved_worker_observation_enabled` field, forwarded as `CreateWorkerRequest.worker_observation_enabled`. Canonical same-Runtime peers may also be supplied through `resolved_worker_observation_grants`; Runtime revalidates those against live weak handles and Workspace scope. For cross-Runtime peers and dynamically added Workers, `WorkspaceClientWorkerObservationProvider` calls the Workspace-scoped Server projection on every list/capture. Server authorizes that route against the current Workspace Orchestrator identity, recomputes the active Workspace Worker set, and reads the selected Workers committed protocol snapshot. Runtime binds these providers through `Worker::bind_worker_observation_provider` on spawn and restore. Parent-owned SubWorkers use the same provider contract through `SpawnedSubWorkerObservationProvider`; their subjects use the tagged `sub_worker` variant.
Observation is read-only evidence access. It does not authorize Ticket, Memory, Worker, or Workdir mutations and is not completion or approval authority.
## SubWorker output
SubWorkers no longer expose a separate output cursor tool. `SubWorkerList`, `SubWorkerSend`, and `SubWorkerStop` retain parent-owned lifecycle control, while committed child output is read through `worker-observation`. Turn-completion notifications carry no transcript and only tell the parent to inspect the authoritative committed session at a natural boundary.
+1 -1
View File
@@ -253,7 +253,7 @@ Unless explicitly authorized otherwise, final merge, cleanup, design-boundary de
Before closing, verify concrete evidence:
- SubWorker output via `SubWorkerReadOutput`;
- SubWorker committed session via worker-observation tools;
- worktree state and diff;
- validation command output;
- review result;
@@ -52,8 +52,10 @@ backend への proxy はこの現象の直接原因ではない。
## 改善案
- UI 全体で使う ID 生成 helper を用意し、secure context に依存しない実装にする。
`crypto.getRandomValues()` から UUID v4 相当を生成する方法で十分である。
- `WorkspaceMultiplexer` の client/request correlation ID は、module-local の単調増加
sequence から生成する。これらは1ページ・1接続内で重複しなければよく、暗号学的乱数や
永続的なglobal uniquenessは不要なので、`crypto``Math.random()`、時刻依存のfallbackを
使わない。
- `WorkspaceMultiplexer.subscribe()` の同期初期化失敗を Console の diagnostic/state に
反映し、初期値の `connecting` に留まらないようにする。
- LAN 上の平文 HTTP を開発時の対応経路とするなら、insecure context から subscription
@@ -67,3 +69,9 @@ backend への proxy はこの現象の直接原因ではない。
`--host` は Vite の listener を LAN に bind するだけであり、配信 origin を secure
context に変えるものではない。localhost で正常に動くことだけでは、LAN IP の HTTP
アクセスでも同じブラウザ API が利用できることを証明できない。
## Resolution (2026-08-06)
`WorkspaceMultiplexer` の client/request ID をmodule-localの単調増加sequenceへ変更した。
これによりWebSocket接続開始経路からsecure-context限定の `crypto.randomUUID()` 依存を除去した。
IDはlocal correlationにのみ使われ、認証・認可・capability authorityには使われない。
@@ -0,0 +1,49 @@
# Request-local image injection bypassed durable history and pruning
Date: 2026-08-06
## Symptom
The first `ViewImage` implementation stored image bytes in `ToolOutput.attachments` with
`#[serde(skip)]`, projected them into a synthetic user image only while building one provider
request, and then cleared the attachment from Worker history. The model could therefore answer
from pixels that disappeared from the next turn, session restore, pruning, and compaction
observation.
## Why this was wrong
Yoi's context policy requires new model-visible input to be appended and committed to
`worker.history` before request construction. Pure pruning may alter a request-context clone
because the projection is deterministic from durable history; request-local input injection is
not equivalent.
The transient image also changed the middle of the next prompt prefix without an explicit prune,
which reduced prompt-cache reuse and left later turns without the evidence behind the assistant
response.
Local reference review confirmed the intended pattern:
- Codex records `view_image` as image-bearing `FunctionCallOutput` content and explicitly tests
that no separate image message is injected.
- OpenCode persists image file parts in Session state and makes media removal an explicit
compaction/pruning decision.
## Resolution
Image attachments are now durable ToolResult detail:
- Image bytes are base64-serialized through normal Item/session-log persistence.
- OpenAI Responses lowers them to `function_call_output` content items.
- OpenAI Chat deterministically lowers the durable ToolResult to tool text followed by a user
image message while preserving parallel-tool-result ordering.
- The existing ToolResult pruning projection removes both text detail and image attachments from
the request-context clone while retaining the summary and original persistent history.
- Session exploration and compaction-facing projections expose only bounded attachment metadata,
never raw base64 as ordinary text.
## Guardrail
Do not use `#[serde(skip)]`, post-build clearing, or one-shot synthetic messages for input that can
influence model output. Provider-specific synthetic messages are acceptable only when they are a
stable projection of a committed history item and are regenerated identically until an explicit
pruning or compaction boundary.
@@ -0,0 +1,32 @@
# SubWorker completion notification did not wake an idle parent
Date: 2026-08-06
Ticket: `00001KZKNWP5X`
## Observed behavior
A Reviewer SubWorker finished after its parent Worker had returned to idle. Although the completion notification was marked `auto_run: true`, the parent did not run until the user submitted another message. The notification appeared only in that later turn.
## Root cause
The completion callback called `NotifyBuffer::push_notify(..., true)` directly. A running parent checks that buffer at turn end and can stage a follow-up, but an idle controller waits on its method channel. Writing the buffer alone therefore could not wake an already-idle parent.
## Fix
Normal controller-owned Workers now give the SubWorker tool a weak sender for the parent method channel. Completion is delivered through the existing:
```rust
Method::Notify {
message,
auto_run: true,
}
```
path, which commits the notification through the normal inbox and wakes an idle controller. A `WeakSender` avoids a controller/tool/channel reference cycle that would otherwise keep the controller alive after external handles are dropped. Internal Worker sessions without a controller retain the direct buffer target.
## Regression coverage
- SubWorker completion sends exactly one `Method::Notify { auto_run: true }` to the parent controller channel.
- The controller notification target does not keep the method channel alive after the strong sender is dropped.
- Existing running-parent notification follow-up tests remain green.
- `cargo test -p worker --lib`: 514 passed.
+46
View File
@@ -0,0 +1,46 @@
{
schema_version = 1;
name = "coder-review";
initial = "implement";
states = {
implement = {
instructions = "Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record the concrete repository/test evidence. When the implementation is ready for independent review, request a Flow transition.";
transitions = {
review = {
target = "review";
condition = "The requested implementation is present, the relevant validation has completed, and there is enough bounded repository and session evidence for an independent Reviewer to evaluate the change.";
};
};
};
review = {
instructions = "Spawn one independent Reviewer SubWorker with bounded Ticket, repository, diff, and validation context. Read its committed review through worker observation. Do not review your own implementation or treat a prose status as approval. After the Reviewer returns a typed approval or concrete requested changes, request a Flow transition.";
transitions = {
approved = {
target = "done";
condition = "The latest independent Reviewer attempt for the current implementation completed and approved it, with no later unresolved request_changes finding.";
};
changes_requested = {
target = "fix";
condition = "The latest independent Reviewer attempt for the current implementation requested one or more concrete changes that remain unresolved.";
};
};
};
fix = {
instructions = "Resolve every open Reviewer finding, rerun the validation affected by the fixes, and preserve concrete evidence. Do not claim approval from the prior request_changes review. When the corrected implementation is ready for a new independent review, request a Flow transition.";
transitions = {
review = {
target = "review";
condition = "Every finding from the latest request_changes review has been addressed with relevant validation evidence, and the corrected implementation is ready for a fresh independent Reviewer attempt.";
};
};
};
done = {
instructions = "The Coder implementation and independent review loop is complete.";
terminal = true;
};
};
}
+1
View File
@@ -25,6 +25,7 @@ feature = {
task = { enabled = true; };
memory = { enabled = true; };
web = { enabled = true; };
image = { enabled = true; };
sub_worker = { enabled = true; };
worker = { enabled = false; };
objective = { enabled = true; };
+1
View File
@@ -8,6 +8,7 @@ import "./base.dcdl" // {
memory = { enabled = true; };
web = { enabled = true; };
sub_worker = { enabled = true; };
flow = { enabled = true; };
worker = { enabled = false; };
ticket = { enabled = true; thread = true; };
};
+1 -1
View File
@@ -7,7 +7,7 @@ import "./base.dcdl" // {
task = { enabled = true; };
memory = { enabled = true; };
web = { enabled = true; };
sub_worker = { enabled = true; };
sub_worker = { enabled = false; };
worker = { enabled = true; };
manage_workdir = { enabled = true; };
ticket = { enabled = true; thread = true; orchestration_control = true; };

Some files were not shown because too many files have changed in this diff Show More