Add agent graph store interface (#19229)

## Summary

Persisted subagent parent/child topology currently leaks through
`StateRuntime`'s SQLite-specific thread-spawn helpers. This PR
introduces a narrow `AgentGraphStore` boundary so follow-up work can
route graph operations through a local or remote store without coupling
orchestration code directly to the state DB graph API.

## Changes

- Adds the new `codex-agent-graph-store` crate.
- Defines a flat `AgentGraphStore` trait for the v1 graph surface:
upsert edge, set edge status, list direct children, and list
descendants.
- Adds public graph types for `ThreadSpawnEdgeStatus`,
`AgentGraphStoreError`, and `AgentGraphStoreResult`.
- Implements `LocalAgentGraphStore` on top of an existing
`codex_state::StateRuntime`, preserving today's SQLite-backed
`thread_spawn_edges` behavior.
- Registers the crate in Cargo/Bazel metadata.

This PR only adds the local contract and implementation; call-site
migration and the remote gRPC store are left to the follow-up PRs in the
stack.

## Testing

- `cargo test -p codex-agent-graph-store`

The new unit tests cover local parity with the existing `StateRuntime`
graph methods, `Open`/`Closed` filtering, status updates, and stable
breadth-first descendant ordering.
This commit is contained in:
Rasmus Rygaard
2026-04-29 15:48:26 -07:00
committed by GitHub
Unverified
parent e20391e567
commit 782191547c
10 changed files with 573 additions and 0 deletions
+70
View File
@@ -134,6 +134,15 @@ ON CONFLICT(child_thread_id) DO UPDATE SET
.await
}
/// List all direct spawned children of `parent_thread_id`.
pub async fn list_thread_spawn_children(
&self,
parent_thread_id: ThreadId,
) -> anyhow::Result<Vec<ThreadId>> {
self.list_thread_spawn_children_matching(parent_thread_id, /*status*/ None)
.await
}
/// List spawned descendants of `root_thread_id` whose edges match `status`.
///
/// Descendants are returned breadth-first by depth, then by thread id for stable ordering.
@@ -1871,4 +1880,65 @@ mod tests {
.expect("all descendants should load");
assert_eq!(all_descendants, vec![child_thread_id, grandchild_thread_id]);
}
#[tokio::test]
async fn thread_spawn_children_without_status_filter_lists_all_statuses() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home, "test-provider".to_string())
.await
.expect("state db should initialize");
let parent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000910").expect("valid thread id");
let open_child_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000911").expect("valid thread id");
let closed_child_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000912").expect("valid thread id");
let future_child_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000913").expect("valid thread id");
runtime
.upsert_thread_spawn_edge(
parent_thread_id,
open_child_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
)
.await
.expect("open child edge insert should succeed");
runtime
.upsert_thread_spawn_edge(
parent_thread_id,
closed_child_thread_id,
DirectionalThreadSpawnEdgeStatus::Closed,
)
.await
.expect("closed child edge insert should succeed");
sqlx::query(
r#"
INSERT INTO thread_spawn_edges (
parent_thread_id,
child_thread_id,
status
) VALUES (?, ?, ?)
"#,
)
.bind(parent_thread_id.to_string())
.bind(future_child_thread_id.to_string())
.bind("future")
.execute(runtime.pool.as_ref())
.await
.expect("future-status child edge insert should succeed");
let children = runtime
.list_thread_spawn_children(parent_thread_id)
.await
.expect("all children should load");
assert_eq!(
children,
vec![
open_child_thread_id,
closed_child_thread_id,
future_child_thread_id,
]
);
}
}