feat: agent-to-agent platform on ZeroClaw 0.8.2 — rooms, delegation, A2A ingress
Builds on the v0.8.2 runtime. Four workstreams, all behind the §15 MCP door:
- Group rooms (Phase 1): migration 0026; N-way threads repo with a DM/room
count-guard; chat.send {room} + room.create/invite/leave tools; RoomMessage
-> room.message SSE; /api/claw-chat/rooms* APIs; Observer room badge.
- Per-claw door identity: door caller_agent resolves the X-ZeroClaw-Agent
header (set by the fork) to the specific claw, falling back to roster[0].
- Gated delegation bridge (Phase 3): clawmates__delegate door tool drives a
sibling via the existing /ws/chat ZeroClawDriveExecutor (not A2A); self-deny,
per-workspace hourly budget, audit trail, untrusted-banner result. Native
in-daemon delegation stays off (it would bypass the door).
- A2A tenant ingress (Phase 2): migration 0027 (workspace_a2a + a2a_tokens);
runtime_provision enable_a2a_server/publish_claw; routes/a2a.rs tenant-aware
proxy (per-workspace tokens, injected internal bearer, daemon stays internal,
cards URL-rewritten to the cm-api edge); a2a.invoked taxonomy.
Tests: cm-db room repos, cm-runtime chat tools, door units. sqlx cache updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1a531e91cd
commit
cbfa0ff24f
@@ -5,13 +5,16 @@ use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
/// An inter-agent conversation (§7.2 Claw Chat).
|
||||
/// An inter-agent conversation (§7.2 Claw Chat). `kind` is `dm` for the 1:1
|
||||
/// threads `find_or_create` makes, or `room` for N-way group rooms.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Thread {
|
||||
pub id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub subject: String,
|
||||
pub sensitivity: String,
|
||||
pub kind: String,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub participants: Vec<Uuid>,
|
||||
pub last_preview: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
@@ -30,7 +33,9 @@ pub struct ThreadMessage {
|
||||
}
|
||||
|
||||
/// Finds the 1:1 thread between two agents, or creates it with the given
|
||||
/// subject.
|
||||
/// subject. The match is constrained to `kind = 'dm'` threads with exactly the
|
||||
/// two active participants, so a DM never resolves to a group room that happens
|
||||
/// to contain both agents.
|
||||
pub async fn find_or_create(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
@@ -41,10 +46,13 @@ pub async fn find_or_create(
|
||||
let existing = sqlx::query_scalar!(
|
||||
r#"SELECT t.id FROM threads t
|
||||
WHERE t.workspace_id = $1
|
||||
AND t.kind = 'dm'
|
||||
AND EXISTS (SELECT 1 FROM thread_participants p
|
||||
WHERE p.thread_id = t.id AND p.agent_id = $2)
|
||||
WHERE p.thread_id = t.id AND p.agent_id = $2 AND p.active)
|
||||
AND EXISTS (SELECT 1 FROM thread_participants p
|
||||
WHERE p.thread_id = t.id AND p.agent_id = $3)
|
||||
WHERE p.thread_id = t.id AND p.agent_id = $3 AND p.active)
|
||||
AND (SELECT count(*) FROM thread_participants p
|
||||
WHERE p.thread_id = t.id AND p.active) = 2
|
||||
LIMIT 1"#,
|
||||
workspace_id.as_uuid(),
|
||||
a.as_uuid(),
|
||||
@@ -59,7 +67,7 @@ pub async fn find_or_create(
|
||||
let id = Uuid::now_v7();
|
||||
let mut tx = pool.begin().await.map_err(DbError::from)?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO threads (id, workspace_id, subject) VALUES ($1, $2, $3)",
|
||||
"INSERT INTO threads (id, workspace_id, subject, kind) VALUES ($1, $2, $3, 'dm')",
|
||||
id,
|
||||
workspace_id.as_uuid(),
|
||||
subject,
|
||||
@@ -68,9 +76,10 @@ pub async fn find_or_create(
|
||||
.await?;
|
||||
for agent in [a, b] {
|
||||
sqlx::query!(
|
||||
"INSERT INTO thread_participants (thread_id, agent_id) VALUES ($1, $2)",
|
||||
"INSERT INTO thread_participants (thread_id, agent_id, added_by) VALUES ($1, $2, $3)",
|
||||
id,
|
||||
agent.as_uuid(),
|
||||
a.as_uuid(),
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -79,6 +88,134 @@ pub async fn find_or_create(
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Creates an N-way group room with the given participants. `created_by` is the
|
||||
/// agent who opened it (a claw via `room.create`) or `None` for an
|
||||
/// operator-created room. Participants are deduped.
|
||||
pub async fn create_room(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
subject: &str,
|
||||
created_by: Option<AgentId>,
|
||||
participants: &[AgentId],
|
||||
) -> Result<Uuid, DbError> {
|
||||
let id = Uuid::now_v7();
|
||||
let creator = created_by.map(|a| a.as_uuid());
|
||||
let mut tx = pool.begin().await.map_err(DbError::from)?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO threads (id, workspace_id, subject, kind, created_by)
|
||||
VALUES ($1, $2, $3, 'room', $4)",
|
||||
id,
|
||||
workspace_id.as_uuid(),
|
||||
subject,
|
||||
creator,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for agent in participants.iter().copied() {
|
||||
if !seen.insert(agent.as_uuid()) {
|
||||
continue;
|
||||
}
|
||||
sqlx::query!(
|
||||
"INSERT INTO thread_participants (thread_id, agent_id, added_by) VALUES ($1, $2, $3)",
|
||||
id,
|
||||
agent.as_uuid(),
|
||||
creator,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await.map_err(DbError::from)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Adds (or re-activates) a participant in a thread. `added_by` is the inviter,
|
||||
/// or `None` for an operator action.
|
||||
pub async fn add_participant(
|
||||
pool: &PgPool,
|
||||
thread_id: Uuid,
|
||||
agent: AgentId,
|
||||
added_by: Option<AgentId>,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO thread_participants (thread_id, agent_id, added_by, active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT (thread_id, agent_id)
|
||||
DO UPDATE SET active = true, added_by = EXCLUDED.added_by",
|
||||
thread_id,
|
||||
agent.as_uuid(),
|
||||
added_by.map(|a| a.as_uuid()),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Soft-removes a participant (keeps their messages attributable).
|
||||
pub async fn remove_participant(
|
||||
pool: &PgPool,
|
||||
thread_id: Uuid,
|
||||
agent: AgentId,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE thread_participants SET active = false
|
||||
WHERE thread_id = $1 AND agent_id = $2",
|
||||
thread_id,
|
||||
agent.as_uuid(),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Active participant ids in a thread (for message fan-out + event emission).
|
||||
pub async fn participants(pool: &PgPool, thread_id: Uuid) -> Result<Vec<Uuid>, DbError> {
|
||||
let rows = sqlx::query_scalar!(
|
||||
"SELECT agent_id FROM thread_participants WHERE thread_id = $1 AND active",
|
||||
thread_id,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// All group rooms in a workspace (human/operator room list).
|
||||
pub async fn list_rooms_for_workspace(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
) -> Result<Vec<Thread>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT t.id, t.workspace_id, t.subject, t.sensitivity, t.kind,
|
||||
t.created_by, t.created_at,
|
||||
ARRAY(SELECT p2.agent_id FROM thread_participants p2
|
||||
WHERE p2.thread_id = t.id AND p2.active) AS "participants!",
|
||||
(SELECT m.content->>'text' FROM thread_messages m
|
||||
WHERE m.thread_id = t.id
|
||||
ORDER BY m.created_at DESC LIMIT 1) AS last_preview
|
||||
FROM threads t
|
||||
WHERE t.workspace_id = $1 AND t.kind = 'room'
|
||||
ORDER BY (SELECT max(m.created_at) FROM thread_messages m
|
||||
WHERE m.thread_id = t.id) DESC NULLS LAST"#,
|
||||
workspace_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| Thread {
|
||||
id: r.id,
|
||||
workspace_id: r.workspace_id,
|
||||
subject: r.subject,
|
||||
sensitivity: r.sensitivity,
|
||||
kind: r.kind,
|
||||
created_by: r.created_by,
|
||||
participants: r.participants,
|
||||
last_preview: r.last_preview,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn add_message(
|
||||
pool: &PgPool,
|
||||
thread_id: Uuid,
|
||||
@@ -105,15 +242,16 @@ pub async fn add_message(
|
||||
/// last message preview (§7.2 thread list).
|
||||
pub async fn list_for_agent(pool: &PgPool, agent_id: AgentId) -> Result<Vec<Thread>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT t.id, t.workspace_id, t.subject, t.sensitivity, t.created_at,
|
||||
r#"SELECT t.id, t.workspace_id, t.subject, t.sensitivity, t.kind,
|
||||
t.created_by, t.created_at,
|
||||
ARRAY(SELECT p2.agent_id FROM thread_participants p2
|
||||
WHERE p2.thread_id = t.id) AS "participants!",
|
||||
WHERE p2.thread_id = t.id AND p2.active) AS "participants!",
|
||||
(SELECT m.content->>'text' FROM thread_messages m
|
||||
WHERE m.thread_id = t.id
|
||||
ORDER BY m.created_at DESC LIMIT 1) AS last_preview
|
||||
FROM threads t
|
||||
JOIN thread_participants p ON p.thread_id = t.id
|
||||
WHERE p.agent_id = $1
|
||||
WHERE p.agent_id = $1 AND p.active
|
||||
ORDER BY (SELECT max(m.created_at) FROM thread_messages m
|
||||
WHERE m.thread_id = t.id) DESC NULLS LAST"#,
|
||||
agent_id.as_uuid(),
|
||||
@@ -127,6 +265,8 @@ pub async fn list_for_agent(pool: &PgPool, agent_id: AgentId) -> Result<Vec<Thre
|
||||
workspace_id: r.workspace_id,
|
||||
subject: r.subject,
|
||||
sensitivity: r.sensitivity,
|
||||
kind: r.kind,
|
||||
created_by: r.created_by,
|
||||
participants: r.participants,
|
||||
last_preview: r.last_preview,
|
||||
created_at: r.created_at,
|
||||
@@ -146,6 +286,24 @@ pub async fn messages(pool: &PgPool, thread_id: Uuid) -> Result<Vec<ThreadMessag
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The workspace a thread belongs to, if it exists (API scoping).
|
||||
pub async fn workspace_of(pool: &PgPool, thread_id: Uuid) -> Result<Option<Uuid>, DbError> {
|
||||
Ok(
|
||||
sqlx::query_scalar!("SELECT workspace_id FROM threads WHERE id = $1", thread_id)
|
||||
.fetch_optional(pool)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
/// A thread's subject line, if it exists.
|
||||
pub async fn subject(pool: &PgPool, thread_id: Uuid) -> Result<Option<String>, DbError> {
|
||||
Ok(
|
||||
sqlx::query_scalar!("SELECT subject FROM threads WHERE id = $1", thread_id)
|
||||
.fetch_optional(pool)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether an agent participates in a thread (API scoping).
|
||||
pub async fn is_participant(
|
||||
pool: &PgPool,
|
||||
@@ -153,7 +311,7 @@ pub async fn is_participant(
|
||||
agent_id: AgentId,
|
||||
) -> Result<bool, DbError> {
|
||||
let row = sqlx::query_scalar!(
|
||||
"SELECT 1 AS x FROM thread_participants WHERE thread_id = $1 AND agent_id = $2",
|
||||
"SELECT 1 AS x FROM thread_participants WHERE thread_id = $1 AND agent_id = $2 AND active",
|
||||
thread_id,
|
||||
agent_id.as_uuid(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user