Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions crates/graphql-orm-ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion crates/graphql-orm-ai/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "graphql-orm-ai"
version = "0.96.0"
version = "0.96.1"
edition = "2024"
authors = ["Toby Martin <toby@dastari.net>"]
description = "Project-agnostic AI agent runtime for graphql-orm applications"
Expand Down
16 changes: 16 additions & 0 deletions crates/graphql-orm-ai/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion crates/graphql-orm-ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<reviewed-full-40-character-commit-sha>", version = "0.96.0", default-features = false, features = ["sqlite"] }
graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.96.1", default-features = false, features = ["sqlite"] }
```

Exactly one persistence backend is required: `sqlite` (default), `postgres`,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/graphql-orm-ai/docs/implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
126 changes: 111 additions & 15 deletions crates/graphql-orm-ai/src/providers/codex_app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3556,7 +3556,8 @@ pub struct AiCodexAppServerProtocolActor {
responded_dynamic_calls: BTreeMap<String, String>,
readiness_probe_tool_id: Option<String>,
started_items: BTreeMap<String, String>,
completed_items: BTreeSet<String>,
completed_items: BTreeMap<String, String>,
accepted_item_lifecycle_starts: usize,
initialization_complete: bool,
thread_lifecycle_phase: ThreadLifecyclePhase,
thread_lifecycle_operation: Option<ThreadLifecycleOperation>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 })
}
Expand Down Expand Up @@ -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())
Expand All @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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 =
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/plans/active/ai-production-readiness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/workspace-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down