diff --git a/Cargo.lock b/Cargo.lock index 89642bc..e194d5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.96.0" +version = "0.96.1" dependencies = [ "agql-auth", "async-graphql", diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 9e1175a..ebacf8a 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,6 +18,27 @@ checkpoint facts. For the current workspace baseline and active gates, use the [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## [0.96.1] - 2026-09-02 + +Persistent schema module: **0.64.0** (unchanged from 0.96.0). + +### Fixed + +- A correlated Codex turn may now continue streaming when a later assistant + segment reopens an already completed `agentMessage` identifier. Multi-step + tool turns no longer require provider-session recovery solely because this + side-effect-free lifecycle identifier was reused. + +### Security + +- Reopening is restricted to a completed identifier whose stored type and new + type are both exactly `agentMessage`. Duplicate-active identifiers, type + changes, reasoning, tool and web-search reuse, malformed correlation, and + more than 256 tracked item starts in one turn remain fail-closed. + +There is no database, data, table, column, index, constraint, backfill, +protected-payload, GraphQL SDL, backup, or restore migration. + ## [0.96.0] - 2026-09-02 Persistent schema module: **0.64.0** (unchanged from 0.95.14). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index 6e977ba..01ae181 100644 --- a/crates/graphql-orm-ai/Cargo.toml +++ b/crates/graphql-orm-ai/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-ai" -version = "0.96.0" +version = "0.96.1" edition = "2024" authors = ["Toby Martin "] description = "Project-agnostic AI agent runtime for graphql-orm applications" diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index 0c3f750..e293eb2 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,22 @@ they describe. For the current workspace baseline and active delivery gates, use [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## 0.96.0 to 0.96.1: bounded assistant-item lifecycle continuation + +Adopt `graphql-orm-ai` 0.96.1 from one reviewed full monorepo revision. No host +API or configuration change is required. The Codex protocol actor now admits a +later `agentMessage` start that reuses an identifier previously completed as +the same side-effect-free item type inside the correlated turn. Hosts continue +processing its ordinary bounded text deltas and completion. + +The actor separately counts tracked item starts, so identifier reuse cannot +bypass the 256-item turn ceiling. Duplicate-active identifiers, type changes, +reasoning, dynamic-tool and web-search reuse, malformed frames, and mismatched +turns remain rejected. + +The AI schema module remains **0.64.0**. There is no database, data, GraphQL +SDL, protected-payload, backup, restore, or data backfill migration. + ## 0.95.14 to 0.96.0: retained-plan recovery and no-dispatch retry proof Adopt `graphql-orm-ai` 0.96.0 from one reviewed full monorepo revision. Hosts diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 10d7ef5..a5d09f7 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -28,7 +28,7 @@ for AI, ORM, storage, backup, and tool-profile packages: ```toml [dependencies] -graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.96.0", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.96.1", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, @@ -237,6 +237,13 @@ per-window count and byte limits. It does not satisfy resume readiness; hosts must continue reading until the correlated response and started notification, or the reviewed content-free usage fallback, complete the lifecycle. +Within one correlated turn, Codex may reopen a completed `agentMessage` +identifier for a later streamed assistant segment. The actor admits only that +same-type, side-effect-free transition and keeps a separate hard ceiling on +tracked item starts. Duplicate-active identifiers, type changes, tool and +web-search identifier reuse, malformed correlation, and over-limit lifecycles +remain rejected. + The Codex schema projector preserves bounded nullable scalar `type` arrays in the crate-authored FixedBroker definitions. It does not pass through arbitrary JSON Schema unions: only unique combinations of supported scalar types plus diff --git a/crates/graphql-orm-ai/docs/implementation-status.md b/crates/graphql-orm-ai/docs/implementation-status.md index 262aa95..2df05c1 100644 --- a/crates/graphql-orm-ai/docs/implementation-status.md +++ b/crates/graphql-orm-ai/docs/implementation-status.md @@ -10,7 +10,7 @@ supersedes: [] # Implementation Status -`graphql-orm-ai` is at crate version `0.96.0` with AI schema module +`graphql-orm-ai` is at crate version `0.96.1` with AI schema module `0.64.0`. It uses workspace `graphql-orm` `0.30.0`, backend-neutral `graphql-orm-ai-tool-profiles` `0.10.4`, and external `agql-auth` `0.19.0` at `1d2e9fe2e1576105212a7b340a11abf8cad0382d`. diff --git a/crates/graphql-orm-ai/src/providers/codex_app_server.rs b/crates/graphql-orm-ai/src/providers/codex_app_server.rs index 181e82a..930ed26 100644 --- a/crates/graphql-orm-ai/src/providers/codex_app_server.rs +++ b/crates/graphql-orm-ai/src/providers/codex_app_server.rs @@ -3556,7 +3556,8 @@ pub struct AiCodexAppServerProtocolActor { responded_dynamic_calls: BTreeMap, readiness_probe_tool_id: Option, started_items: BTreeMap, - completed_items: BTreeSet, + completed_items: BTreeMap, + accepted_item_lifecycle_starts: usize, initialization_complete: bool, thread_lifecycle_phase: ThreadLifecyclePhase, thread_lifecycle_operation: Option, @@ -3605,7 +3606,8 @@ impl AiCodexAppServerProtocolActor { responded_dynamic_calls: BTreeMap::new(), readiness_probe_tool_id: None, started_items: BTreeMap::new(), - completed_items: BTreeSet::new(), + completed_items: BTreeMap::new(), + accepted_item_lifecycle_starts: 0, initialization_complete: false, thread_lifecycle_phase: ThreadLifecyclePhase::Ready, thread_lifecycle_operation: None, @@ -3726,6 +3728,7 @@ impl AiCodexAppServerProtocolActor { || !self.responded_dynamic_calls.is_empty() || !self.started_items.is_empty() || !self.completed_items.is_empty() + || self.accepted_item_lifecycle_starts != 0 || self.active_web_search_maximum_calls.is_some() || self.started_web_search_calls != 0 { @@ -4308,6 +4311,7 @@ impl AiCodexAppServerProtocolActor { || self.turn_started_observed || !self.started_items.is_empty() || !self.completed_items.is_empty() + || self.accepted_item_lifecycle_starts != 0 || self.readiness_probe_tool_id.is_some() || self.thread_web_search_domain_policy != input @@ -4594,6 +4598,7 @@ impl AiCodexAppServerProtocolActor { self.started_web_search_calls = 0; self.started_items.clear(); self.completed_items.clear(); + self.accepted_item_lifecycle_starts = 0; } Ok(AiCodexAppServerInbound::Notification { method: method.to_owned(), @@ -4754,19 +4759,23 @@ impl AiCodexAppServerProtocolActor { let completed = method == "item/completed"; if completed { if self.started_items.remove(item_id).as_deref() != Some("reasoning") - || !self.completed_items.insert(item_id.to_owned()) - || self.completed_items.len() > MAXIMUM_TEXT_BLOCKS + || self + .completed_items + .insert(item_id.to_owned(), "reasoning".to_owned()) + .is_some() { return Err(ProviderError::Rejected); } - } else if self.completed_items.contains(item_id) + } else if self.completed_items.contains_key(item_id) + || self.accepted_item_lifecycle_starts >= MAXIMUM_TEXT_BLOCKS || self .started_items .insert(item_id.to_owned(), "reasoning".to_owned()) .is_some() - || self.started_items.len() > MAXIMUM_TEXT_BLOCKS { return Err(ProviderError::Rejected); + } else { + self.accepted_item_lifecycle_starts += 1; } Ok(AiCodexAppServerInbound::ReasoningLifecycle { completed }) } @@ -4822,7 +4831,8 @@ impl AiCodexAppServerProtocolActor { || item.get("query").and_then(Value::as_str) != Some("") || item.get("results").is_some_and(|value| !value.is_null()) || self.started_web_search_calls >= maximum_calls - || self.completed_items.contains(&call_id) + || self.completed_items.contains_key(&call_id) + || self.accepted_item_lifecycle_starts >= MAXIMUM_TEXT_BLOCKS || self .started_items .insert(call_id.clone(), "webSearch".to_owned()) @@ -4831,6 +4841,7 @@ impl AiCodexAppServerProtocolActor { return Err(ProviderError::Rejected); } self.started_web_search_calls += 1; + self.accepted_item_lifecycle_starts += 1; return Ok(AiCodexAppServerInbound::WebSearchLifecycle { turn_id, call_id, @@ -4841,8 +4852,10 @@ impl AiCodexAppServerProtocolActor { }); } if self.started_items.remove(&call_id).as_deref() != Some("webSearch") - || !self.completed_items.insert(call_id.clone()) - || self.completed_items.len() > MAXIMUM_TEXT_BLOCKS + || self + .completed_items + .insert(call_id.clone(), "webSearch".to_owned()) + .is_some() { return Err(ProviderError::Rejected); } @@ -5258,22 +5271,30 @@ impl AiCodexAppServerProtocolActor { .and_then(Value::as_str) .ok_or(ProviderError::Rejected)?; if method == "item/started" { - if self.completed_items.contains(item_id) - || self.started_items.contains_key(item_id) - || self.started_items.len() >= MAXIMUM_TEXT_BLOCKS + let reopens_completed_agent_message = item_type == "agentMessage" + && self.completed_items.get(item_id).map(String::as_str) + == Some("agentMessage"); + if self.started_items.contains_key(item_id) + || self.accepted_item_lifecycle_starts >= MAXIMUM_TEXT_BLOCKS + || (self.completed_items.contains_key(item_id) + && !reopens_completed_agent_message) { return Err(ProviderError::Rejected); } + if reopens_completed_agent_message { + self.completed_items.remove(item_id); + } self.started_items .insert(item_id.to_owned(), item_type.to_owned()); + self.accepted_item_lifecycle_starts += 1; } else { if self.started_items.get(item_id).map(String::as_str) != Some(item_type) - || self.completed_items.contains(item_id) - || self.completed_items.len() >= MAXIMUM_TEXT_BLOCKS + || self.completed_items.contains_key(item_id) { return Err(ProviderError::Rejected); } - self.completed_items.insert(item_id.to_owned()); + self.completed_items + .insert(item_id.to_owned(), item_type.to_owned()); self.started_items.remove(item_id); } } @@ -13448,6 +13469,81 @@ pub(crate) mod tests { )); } + #[test] + fn protocol_reopens_only_completed_agent_message_identifiers_within_the_turn_bound() { + let started = |timestamp: i64, item_type: &str| { + lifecycle_notification( + "item/started", + json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": timestamp, + "item": {"id": "message-reused", "type": item_type, "text": ""}, + }), + ) + }; + let completed = |timestamp: i64| { + lifecycle_notification( + "item/completed", + json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "completedAtMs": timestamp, + "item": { + "id": "message-reused", + "type": "agentMessage", + "text": "complete", + }, + }), + ) + }; + + let mut actor = active_protocol_actor(); + actor + .accept(&started(1, "agentMessage")) + .expect("first agent-message lifecycle should start"); + actor + .accept(&completed(2)) + .expect("first agent-message lifecycle should complete"); + actor + .accept(&started(3, "agentMessage")) + .expect("a later agent-message segment may reuse the completed identifier"); + actor + .accept(&lifecycle_notification( + "item/agentMessage/delta", + json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "itemId": "message-reused", + "delta": "continued", + }), + )) + .expect("the reopened identifier should bind subsequent text deltas"); + actor + .accept(&completed(4)) + .expect("the reopened lifecycle should complete"); + assert!(matches!( + actor.accept(&started(5, "userMessage")), + Err(ProviderError::Rejected) + )); + + let mut bounded = active_protocol_actor(); + for occurrence in 0..MAXIMUM_TEXT_BLOCKS { + let started_at = i64::try_from(occurrence * 2 + 1).expect("test timestamp should fit"); + let completed_at = started_at + 1; + bounded + .accept(&started(started_at, "agentMessage")) + .expect("bounded reused lifecycle should start"); + bounded + .accept(&completed(completed_at)) + .expect("bounded reused lifecycle should complete"); + } + assert!(matches!( + bounded.accept(&started(10_000, "agentMessage")), + Err(ProviderError::Rejected) + )); + } + #[test] fn experimental_protocol_admits_only_an_exact_offered_dynamic_call_and_response() { let mut guard = diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 4712615..6048e64 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1329,7 +1329,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.96.0" +version = "0.96.1" dependencies = [ "agql-auth", "async-graphql", diff --git a/docs/plans/active/ai-production-readiness/README.md b/docs/plans/active/ai-production-readiness/README.md index 2e0ce3a..a255e1c 100644 --- a/docs/plans/active/ai-production-readiness/README.md +++ b/docs/plans/active/ai-production-readiness/README.md @@ -59,7 +59,7 @@ retention, or restore proofs remain closed. ## Current checkpoint -Package 0.96.0 and AI schema module 0.64.0 provide the protected runtime, +Package 0.96.1 and AI schema module 0.64.0 provide the protected runtime, provider adapters, exact completed-batch adoption, retention foundations, restore planning, and readiness observation contracts. Database-derived collection covers bounded conservative run classification, approval and diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index dca6b74..668d40d 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -19,7 +19,7 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | | `graphql-orm` | `0.30.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.96.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai` | `0.96.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.10.4` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `graphql-orm-macros` | `0.30.0` | `crates/graphql-orm-macros` | `sqlite` | none |