fix(graph): auto-merge only schema-declared keys from llm output
state_updates::apply merged EVERY top-level key of an llm/agent node's JSON
output into graph state whenever the node had an output_schema, and
structured::extract never validated the output against that schema. Any
prompt text able to steer the model — including plan/diff text pasted from a
repo under review — could therefore plant an undeclared key (e.g.
verification_commands) into state and overwrite a caller-declared value
before a downstream script consumed it.
- apply() now takes the node's output_schema and merges only keys present in
schema.properties; undeclared keys are dropped. No schema, or a schema
without an object properties map, merges nothing.
- Nodes without output_schema are unchanged (None == the old false path);
explicit state_updates are unchanged and still see the full output via
{{output.<key>}}, which remains the author-controlled way to lift a key.
- Callers: llm.rs, agent.rs (x2), rag.rs (None).
- Sweep test bundled_graph_output_schemas_declare_every_key_relied_on: for
every bundled graph, every consumed state key has a declared writer
(initial_state, variable, state_updates target, map key, script-emitted key,
or an output_schema property), and no schema node's field list names a key
its schema omits — so this restriction cannot silently break a shipped
graph. It passes with no graph.yaml changes.
- graph.example.yaml documents the declared-keys-only merge; the residual
disclosed in 665fee9's README/docstring notes is now recorded as closed.
This commit is contained in:
1 parent
300c7aeff0
commit
27101909e0
9 files changed
+370
-43
No files matched your search
+17
-5
@@ -71,7 +71,7 @@ fn outcome_from(
|
||||
state_updates::apply(
|
||||
state_manager,
|
||||
&Value::String(format!("Agent node failed: {e:#}")),
|
||||
node.output_schema.is_some(),
|
||||
node.output_schema.as_ref(),
|
||||
node.state_updates.as_ref(),
|
||||
);
|
||||
Ok(AgentExecutionOutcome::FellBack(fb.clone()))
|
||||
@@ -309,7 +309,7 @@ fn apply_state_updates(node: &AgentNode, state_manager: &mut StateManager, outpu
|
||||
state_updates::apply(
|
||||
state_manager,
|
||||
output,
|
||||
node.output_schema.is_some(),
|
||||
node.output_schema.as_ref(),
|
||||
node.state_updates.as_ref(),
|
||||
);
|
||||
}
|
||||
@@ -1090,7 +1090,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn output_schema_auto_merges_top_level_keys() {
|
||||
let node = node_with_schema("hi", None, json!({"type": "object"}));
|
||||
let node = node_with_schema(
|
||||
"hi",
|
||||
None,
|
||||
json!({"type": "object", "properties": {"goal": {}, "summary": {}}}),
|
||||
);
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({"goal": "do X", "summary": "details"});
|
||||
|
||||
@@ -1102,7 +1106,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn output_schema_preserves_nested_value_types() {
|
||||
let node = node_with_schema("hi", None, json!({"type": "object"}));
|
||||
let node = node_with_schema(
|
||||
"hi",
|
||||
None,
|
||||
json!({"type": "object", "properties": {"tags": {}, "config": {}, "count": {}}}),
|
||||
);
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({
|
||||
"tags": ["a", "b"],
|
||||
@@ -1121,7 +1129,11 @@ mod tests {
|
||||
fn output_schema_explicit_state_updates_override_auto_merge() {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("goal".into(), "renamed-{{output.goal}}".into());
|
||||
let node = node_with_schema("hi", Some(u), json!({"type": "object"}));
|
||||
let node = node_with_schema(
|
||||
"hi",
|
||||
Some(u),
|
||||
json!({"type": "object", "properties": {"goal": {}}}),
|
||||
);
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({"goal": "do X"});
|
||||
|
||||
|
||||
+13
-4
@@ -520,7 +520,7 @@ fn apply_state_updates_with_output(
|
||||
state_updates::apply(
|
||||
state_manager,
|
||||
output,
|
||||
node.output_schema.is_some(),
|
||||
node.output_schema.as_ref(),
|
||||
node.state_updates.as_ref(),
|
||||
);
|
||||
}
|
||||
@@ -671,7 +671,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn output_schema_auto_merges_top_level_keys() {
|
||||
let node = node_with_schema(None, json!({"type": "object"}));
|
||||
let node = node_with_schema(
|
||||
None,
|
||||
json!({"type": "object", "properties": {"goal": {}, "summary": {}}}),
|
||||
);
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({"goal": "do X", "summary": "details"});
|
||||
|
||||
@@ -683,7 +686,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn output_schema_preserves_nested_value_types() {
|
||||
let node = node_with_schema(None, json!({"type": "object"}));
|
||||
let node = node_with_schema(
|
||||
None,
|
||||
json!({"type": "object", "properties": {"tags": {}, "config": {}, "count": {}}}),
|
||||
);
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({
|
||||
"tags": ["a", "b"],
|
||||
@@ -702,7 +708,10 @@ mod tests {
|
||||
fn output_schema_explicit_state_updates_override_auto_merge() {
|
||||
let mut u = HashMap::new();
|
||||
u.insert("goal".into(), "renamed-{{output.goal}}".into());
|
||||
let node = node_with_schema(Some(u), json!({"type": "object"}));
|
||||
let node = node_with_schema(
|
||||
Some(u),
|
||||
json!({"type": "object", "properties": {"goal": {}}}),
|
||||
);
|
||||
let mut state = manager_with(&[]);
|
||||
let output = json!({"goal": "do X"});
|
||||
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ fn build_rag_output(context: String, sources_str: &str) -> Value {
|
||||
}
|
||||
|
||||
fn apply_state_updates(node: &RagNode, state_manager: &mut StateManager, output: &Value) {
|
||||
state_updates::apply(state_manager, output, false, node.state_updates.as_ref());
|
||||
state_updates::apply(state_manager, output, None, node.state_updates.as_ref());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+101
-16
@@ -4,19 +4,32 @@ use std::collections::HashMap;
|
||||
|
||||
pub(super) const OUTPUT_KEY: &str = "output";
|
||||
|
||||
/// Merge `output_schema` top-level keys (when `has_schema`) and apply `state_updates` with
|
||||
/// `{{output}}` bound to `output` for the duration of interpolation only. An explicit `output`
|
||||
/// key in `updates` is honored; otherwise `output` is restored to its prior value, or removed
|
||||
/// again if it was absent.
|
||||
/// Merge the `output` keys declared under `schema["properties"]` and apply `state_updates` with
|
||||
/// `{{output}}` bound to the full, unfiltered `output` for the duration of interpolation only.
|
||||
///
|
||||
/// The schema is the graph author's declaration of what the model may write to state; any other
|
||||
/// key in the output is model- (and therefore prompt-) controlled and is dropped rather than
|
||||
/// merged implicitly. No `schema`, or a schema without an object `properties`, merges nothing.
|
||||
/// Authors who want an undeclared key can still lift it explicitly via
|
||||
/// `state_updates: { key: '{{output.key}}' }`.
|
||||
///
|
||||
/// An explicit `output` key in `updates` is honored; otherwise `output` is restored to its prior
|
||||
/// value, or removed again if it was absent.
|
||||
pub(super) fn apply(
|
||||
state_manager: &mut StateManager,
|
||||
output: &Value,
|
||||
has_schema: bool,
|
||||
schema: Option<&Value>,
|
||||
updates: Option<&HashMap<String, String>>,
|
||||
) {
|
||||
if has_schema && let Some(obj) = output.as_object() {
|
||||
if let Some(declared) = schema
|
||||
.and_then(|s| s.get("properties"))
|
||||
.and_then(Value::as_object)
|
||||
&& let Some(obj) = output.as_object()
|
||||
{
|
||||
for (k, v) in obj {
|
||||
state_manager.state_mut().set(k.clone(), v.clone());
|
||||
if declared.contains_key(k) {
|
||||
state_manager.state_mut().set(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +88,7 @@ mod tests {
|
||||
let u = updates(&[("output", "{{output}}")]);
|
||||
let mut state = manager_with(&[("output", json!("stale"))]);
|
||||
|
||||
apply(&mut state, &json!("the completion"), false, Some(&u));
|
||||
apply(&mut state, &json!("the completion"), None, Some(&u));
|
||||
|
||||
assert_eq!(
|
||||
state.state().get(OUTPUT_KEY),
|
||||
@@ -88,7 +101,7 @@ mod tests {
|
||||
let u = updates(&[("output", "{{output}}"), ("summary", "got: {{output}}")]);
|
||||
let mut state = manager_with(&[]);
|
||||
|
||||
apply(&mut state, &json!("done"), false, Some(&u));
|
||||
apply(&mut state, &json!("done"), None, Some(&u));
|
||||
|
||||
assert_eq!(state.state().get(OUTPUT_KEY), Some(&json!("done")));
|
||||
assert_eq!(state.state().get("summary"), Some(&json!("got: done")));
|
||||
@@ -98,7 +111,7 @@ mod tests {
|
||||
fn no_updates_is_a_noop() {
|
||||
let mut state = manager_with(&[("keep", json!(1))]);
|
||||
|
||||
apply(&mut state, &json!("ignored"), false, None);
|
||||
apply(&mut state, &json!("ignored"), None, None);
|
||||
|
||||
assert_eq!(state.state().get("keep"), Some(&json!(1)));
|
||||
assert!(state.state().get(OUTPUT_KEY).is_none());
|
||||
@@ -109,7 +122,7 @@ mod tests {
|
||||
let u = updates(&[("response", "{{output}}")]);
|
||||
let mut state = manager_with(&[("output", json!("preserved"))]);
|
||||
|
||||
apply(&mut state, &json!("new"), false, Some(&u));
|
||||
apply(&mut state, &json!("new"), None, Some(&u));
|
||||
|
||||
assert_eq!(state.state().get("response"), Some(&json!("new")));
|
||||
assert_eq!(state.state().get(OUTPUT_KEY), Some(&json!("preserved")));
|
||||
@@ -120,7 +133,7 @@ mod tests {
|
||||
let u = updates(&[("response", "{{output}}")]);
|
||||
let mut state = manager_with(&[]);
|
||||
|
||||
apply(&mut state, &json!("new"), false, Some(&u));
|
||||
apply(&mut state, &json!("new"), None, Some(&u));
|
||||
|
||||
assert_eq!(state.state().get("response"), Some(&json!("new")));
|
||||
assert!(state.state().get(OUTPUT_KEY).is_none());
|
||||
@@ -131,23 +144,95 @@ mod tests {
|
||||
let u = updates(&[("response", "{{output}}")]);
|
||||
let mut state = manager_with(&[("output", json!(null))]);
|
||||
|
||||
apply(&mut state, &json!("new"), false, Some(&u));
|
||||
apply(&mut state, &json!("new"), None, Some(&u));
|
||||
|
||||
assert_eq!(state.state().get("response"), Some(&json!("new")));
|
||||
assert_eq!(state.state().get(OUTPUT_KEY), Some(&json!(null)));
|
||||
}
|
||||
|
||||
fn schema_with(keys: &[&str]) -> Value {
|
||||
let props: serde_json::Map<String, Value> = keys
|
||||
.iter()
|
||||
.map(|k| ((*k).to_string(), json!({"type": "string"})))
|
||||
.collect();
|
||||
json!({"type": "object", "properties": props})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_merges_top_level_keys_when_has_schema() {
|
||||
fn schema_merges_declared_top_level_keys() {
|
||||
let schema = schema_with(&["summary", "score"]);
|
||||
let mut state = manager_with(&[]);
|
||||
|
||||
apply(&mut state, &json!({"summary": "s", "score": 7}), true, None);
|
||||
apply(
|
||||
&mut state,
|
||||
&json!({"summary": "s", "score": 7}),
|
||||
Some(&schema),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(state.state().get("summary"), Some(&json!("s")));
|
||||
assert_eq!(state.state().get("score"), Some(&json!(7)));
|
||||
assert!(state.state().get(OUTPUT_KEY).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_drops_undeclared_keys_and_preserves_existing_state() {
|
||||
let schema = schema_with(&["summary"]);
|
||||
let mut state = manager_with(&[("verification_commands", json!(["cargo test"]))]);
|
||||
|
||||
apply(
|
||||
&mut state,
|
||||
&json!({"summary": "s", "verification_commands": ["rm -rf /"]}),
|
||||
Some(&schema),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(state.state().get("summary"), Some(&json!("s")));
|
||||
assert_eq!(
|
||||
state.state().get("verification_commands"),
|
||||
Some(&json!(["cargo test"]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_schema_merges_nothing() {
|
||||
let mut state = manager_with(&[]);
|
||||
|
||||
apply(&mut state, &json!({"summary": "s"}), None, None);
|
||||
|
||||
assert!(state.state().get("summary").is_none());
|
||||
assert!(state.state().get(OUTPUT_KEY).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_without_properties_merges_nothing() {
|
||||
let schema = json!({"type": "object"});
|
||||
let mut state = manager_with(&[]);
|
||||
|
||||
apply(&mut state, &json!({"summary": "s"}), Some(&schema), None);
|
||||
|
||||
assert!(state.state().get("summary").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_updates_still_see_undeclared_output_keys() {
|
||||
let schema = schema_with(&["summary"]);
|
||||
let u = updates(&[("lifted", "{{output.extra}}")]);
|
||||
let mut state = manager_with(&[]);
|
||||
|
||||
apply(
|
||||
&mut state,
|
||||
&json!({"summary": "s", "extra": "e"}),
|
||||
Some(&schema),
|
||||
Some(&u),
|
||||
);
|
||||
|
||||
assert_eq!(state.state().get("summary"), Some(&json!("s")));
|
||||
assert!(state.state().get("extra").is_none());
|
||||
assert_eq!(state.state().get("lifted"), Some(&json!("e")));
|
||||
assert!(state.state().get(OUTPUT_KEY).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_output_is_not_merged_without_schema() {
|
||||
let u = updates(&[("ctx", "{{output.context}}")]);
|
||||
@@ -156,7 +241,7 @@ mod tests {
|
||||
apply(
|
||||
&mut state,
|
||||
&json!({"context": "c", "sources": ["a"]}),
|
||||
false,
|
||||
None,
|
||||
Some(&u),
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user