diff --git a/Cargo.lock b/Cargo.lock index ecb89c12374..067a88f4bdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -993,6 +993,7 @@ dependencies = [ "rand 0.10.1", "serde", "serde_json", + "serde_yaml", "sha2 0.11.0", "subtle", "thiserror 2.0.18", diff --git a/ORCHESTRATOR-HANDOFF-PHASE2.md b/ORCHESTRATOR-HANDOFF-PHASE2.md new file mode 100644 index 00000000000..3d3c905d187 --- /dev/null +++ b/ORCHESTRATOR-HANDOFF-PHASE2.md @@ -0,0 +1,107 @@ +# ORCHESTRATOR-HANDOFF-PHASE2 — Issue #116 Slice 1 (role per agent) + +Branch: `feat/issue-116-agent-roles` (local only — **not pushed**). Worktree-only commits with sign-off. + +## What shipped (Slice 1 only) + +Owner-assigned Crew role on managed agents: + +1. **Storage:** `ManagedAgentRecord.crew_role: Option` (validated free string against day-one taxonomy). +2. **Taxonomy (one place):** `code | content | research | ops` + - Rust: `desktop/src-tauri/src/managed_agents/crew_role.rs` (`TAXONOMY`) + - TS: `desktop/src/features/agents/lib/crewRole.ts` +3. **30179 path:** helpers for `extensions["crew:role"]` + codec unit test (product dual-write of 30179 not required day-one; public projection is authority for clients per spike 0015). +4. **Public projection:** kind `10100` builder emits `["crew-role", ]`; role removal clears the tag. Publish on role change via agent-signed event (best-effort). +5. **Authority:** non-owner role claims ignored (`role_authority_accepts` / `verified_owner_role` RED contracts). +6. **Prompt injection (buzz-acp):** role section composed into system prompt on **every fresh session** when role present; no role ⇒ system prompt byte-identical. Strengthened few-shot for Hermes short-accept gap (0016). +7. **Fresh-session semantics (no respawn):** desktop writes `{app_data}/agents/.crew-role` and sets `BUZZ_ACP_CREW_ROLE_FILE` (+ `BUZZ_ACP_CREW_ROLE`) at spawn; harness re-reads file on session/new (`!rotate` model). +8. **Desktop UI:** Crew role select on instance edit dialog; role chip on managed-agent row. +9. **Docs:** `HERMES.md` (role behavior + display-name convention), `STATE.md` (slice status), `DECISIONS.md` **D-028, D-029, D-030**. + +## Upstream-owned / shared files touched (surgical) + +| File | Why | +|------|-----| +| `crates/buzz-acp/src/lib.rs` | module + PromptContext wiring | +| `crates/buzz-acp/src/config.rs` | `crew_role` / `crew_role_file` config | +| `crates/buzz-acp/src/pool.rs` | inject role into framed system prompt on session/new + legacy format_prompt | +| `crates/buzz-acp/src/crew_role.rs` | **new** Crew-owned composer | +| `desktop/src-tauri/src/managed_agents/types.rs` | `crew_role` field on record + summary | +| `desktop/src-tauri/src/managed_agents/types/requests.rs` | create/update patch field | +| `desktop/src-tauri/src/managed_agents/runtime.rs` | spawn env + summary | +| `desktop/src-tauri/src/commands/agent_models.rs` | update path | +| `desktop/src-tauri/src/commands/agents.rs` | create path | +| `desktop/src-tauri/src/nostr_convert.rs` | stock-consumer comment (unknown tags ignored) | +| Many `ManagedAgentRecord { ... }` fixtures | `crew_role: None` | + +Prefer-new Crew files: + +- `desktop/src-tauri/src/managed_agents/crew_role.rs` +- `desktop/src-tauri/src/commands/crew_role_publish.rs` +- `desktop/src/features/agents/lib/crewRole.ts` +- `desktop/src/features/agents/ui/CrewRoleFields.tsx` + +## Test counts (RED contracts → green) + +**buzz-acp** (`cargo test -p buzz-acp --lib crew_role`): **7 passed** +- no role byte-identical; section iff role; content matches; file re-read fresh-session; taxonomy sections + +**desktop** (`cargo test --manifest-path desktop/src-tauri/Cargo.toml --lib crew_role`): **11 passed** including +- taxonomy / parse / extensions / 30179 codec namespaced key +- projection one tag / removal clears +- non-founder ignored +- build 10100 kind+tag +- managed-agent record serde round-trip +- stock-consumer tag shape safety + +## Spike 0016 matrix re-run (shipped section text + Hermes) + +Profiles: `spike116b-code`, `spike116b-content` (created, used, **deleted**). + +Method: hermes `chat -q` with strengthened role section (spike 0016 assets + few-shot). 5 cases × 2 roles = 10. Mutation via `git status --porcelain`. + +| Metric | Result | +|--------|--------| +| n | 10 | +| ROLE-CHECK present | **10/10** | +| silent off-role mutations | **0** | +| PASS bar (0 silent off-role) | **PASS** | + +Per-case: off-role blog/readme/rename/debug did not mutate; on-role rename/dialog mutated when accepted. + +Evidence: `/tmp/spike116b/out/SUMMARY.json` (disposable). + +## `just ci` (local) + +```text +just ci +# exit 0 +# Includes: cargo fmt/clippy workspace, desktop biome+file-size+unit gates, +# desktop-tauri fmt/clippy, web check, mobile format/analyze/tests, unit test harness. +# Mobile: All tests passed! (1275+) +``` + +Full log: `/tmp/issue116-just-ci.log` + +## Known gaps + +1. **Room announcement** is a **stub** (`tracing::info` only). Projection publish is best-effort agent-signed 10100; durable channel message needs a target channel — follow-up can wire owner `send_channel_message` when a home channel is known. +2. **30179 dual-write** not productized (NIP-PMA private aggregate authority still incomplete upstream); local record + 10100 projection is day-one truth per 0015. +3. Matrix used **hermes chat -q** with role section text matching shipped composer, not a full desktop-spawned buzz-acp process (same soft-enforcement boundary as spike 0016 direct ACP). +4. File-size ratchet: grandfathered large files kept at merge-base line counts via blank-line budget; new logic lives in additive modules. + +## Commands run (exact) + +```bash +cargo test -p buzz-acp --lib crew_role +cargo test --manifest-path desktop/src-tauri/Cargo.toml --lib crew_role +just desktop-tauri-check +just ci +python3 /tmp/spike116b/run-matrix.py +hermes profile delete spike116b-code -y +hermes profile delete spike116b-content -y +``` + +## Non-goals honored + +No Slice 2 presets, no Slice 3 capability flags, no buzz-dev-mcp allowlist, no mobile, no relay-side role enforcement, no auto-routing. Only D-028–D-030 added to DECISIONS. diff --git a/ORCHESTRATOR-HANDOFF.md b/ORCHESTRATOR-HANDOFF.md new file mode 100644 index 00000000000..6759a7f258a --- /dev/null +++ b/ORCHESTRATOR-HANDOFF.md @@ -0,0 +1,86 @@ +# ORCHESTRATOR-HANDOFF — Issue #116 Slice 0 spikes + +Branch: `feat/issue-116-agent-roles` (not pushed). Worktree-only commits with sign-off. + +## Commits + +1. `9bd534945` — docs(crew): spike 0015 role record projection PASS +2. `aa14c85f3` — docs(crew): spike 0016 role prompt adherence matrix PASS +3. `2540c2ac5` — docs(crew): spike 0017 capability spawn grant/deny PASS + +Plan (untracked unless you add it): `plans/20260810-agent-roles-routing-capability/plan.md` + +## Spike A — role record shape and projection — **PASS** + +Record: `docs/crew/spikes/0015-role-record-projection.md` + +Owner-signed kind `30179` carries role via namespaced +`extensions["crew:role"]="code"` (not a top-level field — `deny_unknown_fields`). +Public kind `10100` tag `["crew-role","code"]` survives isolated relay publish + +cold query. Stock `handle_agent_profile` still applies `channel_add_policy` +with the unknown tag present (SQL confirmed). Outer `30179` tags stay +`d/g/state` only. Decision-changing: live ingest currently **accepts** `30179` +even though NIP-PMA draft text says reject-until-CAS — day-one product surface +should still treat **public `10100` projection** as the safe client-visible +role; do not treat accepted `30179` as full private-aggregate authority yet. + +## Spike B — role prompt adherence engine matrix — **PASS** + +Record: `docs/crew/spikes/0016-role-prompt-adherence-matrix.md` + +Engines: Hermes `spike116-code`, Hermes `spike116-content`, Claude Code ACP. +Method: direct ACP 10-case matrix with injected role section (live-relay full +30-mention publish path was flaky for replies; adherence boundary is model +behavior under the role section). + +| Engine | n | ROLE-CHECK | off-role mutations | silent off-role risk | +|--------|---|------------|--------------------|----------------------| +| hermes-code | 10 | 8/10 | 0 | 0 | +| hermes-content | 10 | 9/10 | 0 | 0 | +| claude-code | 10 | 10/10 | 0 | 0 | + +All three engines viable for soft role enforcement day one. Hermes sometimes +drops the mandatory first-line declaration on short accepts — strengthen +few-shot in Slice 1, not a FAIL. Refusals named the correct role in samples. + +## Spike C — capability grant/deny + native half — **PASS** + +Record: `docs/crew/spikes/0017-capability-spawn-grant-deny.md` + +Per-agent `BUZZ_ACP_MCP_COMMAND` works: granted Hermes registered +`buzz-dev-mcp` and wrote the probe file; denied Hermes had empty `mcp_cmd`, no +MCP registration, no probe file; turn loops stayed healthy. + +**Decision-changing for Slice 3 honesty:** + +- Hermes is **not** MCP-only for FS: native terminal/write_file remain when MCP + is withheld. Deny-MCP removes Buzz dev MCP (+ credentialed reply path) but is + not a universal FS floor. +- Claude with empty MCP still wrote via native tools (harness used + `bypassPermissions`). +- Native floors **are** spawn-settable and reproducible: + - Codex: `-s read-only` blocks write; `-s workspace-write` allows (STATE.md + earlier “blocked” note = config, not luck). + - Claude: `--permission-mode plan` blocks; `acceptEdits` allows. + +## Gate for Slice 1 + +All three spikes **PASS**. Orchestrator may approve Slice 1 RED contracts + +implementation planning. No production code was changed in this phase. + +## Cleanup performed / remaining + +- Throwaway Hermes profiles: delete with + `hermes profile delete spike116-code -y` and + `hermes profile delete spike116-content -y` (verify dir absence). +- Teardown isolated stack when finished reviewing: + `tmux kill-session -t spike116-relay` (and any `spike116-*` harness sessions); + `docker compose -p buzz-spike116 -f docker-compose.harness.yml down -v` +- Disposable tree: `/tmp/spike116/` (safe to rm -rf). +- Plan file still untracked under `plans/20260810-agent-roles-routing-capability/` + — commit separately if the orchestrator wants it on the branch. + +## Non-goals honored + +No crates/desktop production edits, no RED tests yet, no DECISIONS/STATE edits, +no push, no PR. diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index fa07e491a42..72299beffb4 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -25,6 +25,9 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; +use buzz_core::crew_role::{ + compose_role_section, count_crew_blocks, resolve_assignment, RoleAssignment, +}; use buzz_worktree::SharedLease; use tokio::sync::mpsc; use tokio::task::{JoinHandle, JoinSet}; @@ -137,7 +140,7 @@ pub struct SessionState { /// Absent when the channel has no canvas, the canvas content is blank, or the /// fetch fails — all fail open. Cleared on session invalidation alongside /// `core_sections` so the next session picks up any canvas change. - pub canvas_sections: HashMap, + pub canvas_sections: HashMap, /// Conversation identity → real NIP-29 channel. pub routing_channels: HashMap, /// Conversation identity → last verified Project workspace binding. @@ -146,6 +149,12 @@ pub struct SessionState { pub workspace_bindings: HashMap, } +#[derive(Debug, Clone)] +pub struct CanvasSessionContext { + pub rendered: String, + pub role: Option, +} + impl SessionState { /// Invalidate the session (and turn counter) for a specific prompt source. pub fn invalidate(&mut self, source: &PromptSource) { @@ -958,6 +967,7 @@ async fn create_session_and_apply_model( session_cwd: &str, agent_core: Option<&str>, agent_canvas: Option<&str>, + role_assignment: Option<&RoleAssignment>, channel_name: Option<&str>, channel_id: Option, channel_type: Option<&str>, @@ -969,10 +979,14 @@ async fn create_session_and_apply_model( // its own `[Agent Memory — core]` header, and canvas carries its own // `[Channel Canvas]` header; both are appended with a blank-line separator. let is_goose = agent.agent_name == "goose"; + let system_with_role = role_assignment + .map(|assignment| compose_role_section(assignment)) + .map(|section| combine_optional_prompt(ctx.system_prompt.as_deref(), Some(§ion))) + .unwrap_or_else(|| ctx.system_prompt.clone()); let combined_system_prompt = with_canvas( with_core( with_team( - framed_system_prompt(session_cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), + framed_system_prompt(session_cwd, ctx.base_prompt, system_with_role.as_deref()), ctx.team_instructions.as_deref(), ), agent_core, @@ -1415,6 +1429,18 @@ fn with_canvas(prompt: Option, canvas: Option<&str>) -> Option { } } +fn combine_optional_prompt(base: Option<&str>, addition: Option<&str>) -> Option { + match ( + base.map(str::trim).filter(|value| !value.is_empty()), + addition.map(str::trim).filter(|value| !value.is_empty()), + ) { + (Some(base), Some(addition)) => Some(format!("{base}\n\n{addition}")), + (Some(base), None) => Some(base.to_string()), + (None, Some(addition)) => Some(addition.to_string()), + (None, None) => None, + } +} + /// Return `agent` to the pool via `result_tx`, clearing any steer receiver first. /// /// Every path that returns an `OwnedAgent` to the pool via `PromptResult` goes @@ -1707,7 +1733,7 @@ pub async fn run_prompt_task( // commit it to `canvas_sections` only after session creation succeeds. This // prevents a stale revision A surviving a failed create and being re-used by // the next attempt after the canvas was cleared. - let mut pending_canvas: Option<(Uuid, String)> = None; + let mut pending_canvas: Option<(Uuid, CanvasSessionContext)> = None; // Channel name for the session title, from the same single resolve the // canvas DM check uses — see `resolve_new_session_channel_context`. let mut title_channel: Option = None; @@ -1725,8 +1751,13 @@ pub async fn run_prompt_task( // A confirmed DM never receives a canvas section; an undeterminable // channel type fails closed as a DM for the same reason. if needs_canvas && !is_dm { - if let Some(section) = - fetch_canvas_section(routing_channel_id, &ctx.rest_client).await + if let Some(section) = fetch_canvas_section( + routing_channel_id, + &ctx.rest_client, + ctx.agent_owner_pubkey.as_ref(), + &ctx.agent_keys.public_key(), + ) + .await { pending_canvas = Some((*cid, section)); } @@ -1748,8 +1779,17 @@ pub async fn run_prompt_task( .state .canvas_sections .get(cid) - .cloned() - .or_else(|| pending_canvas.as_ref().map(|(_, s)| s.clone())), + .map(|section| section.rendered.clone()) + .or_else(|| pending_canvas.as_ref().map(|(_, s)| s.rendered.clone())), + PromptSource::Heartbeat => None, + }; + let role_assignment = match &source { + PromptSource::Channel(cid) => agent + .state + .canvas_sections + .get(cid) + .and_then(|section| section.role.clone()) + .or_else(|| pending_canvas.as_ref().and_then(|(_, s)| s.role.clone())), PromptSource::Heartbeat => None, }; @@ -1768,6 +1808,7 @@ pub async fn run_prompt_task( &session_cwd, agent_core.as_deref(), agent_canvas.as_deref(), + role_assignment.as_ref(), title_channel.as_deref(), Some(*cid), origin_channel_type.as_deref(), @@ -1823,7 +1864,7 @@ pub async fn run_prompt_task( (sid.clone(), false) } else { match create_session_and_apply_model( - &mut agent, &ctx, &ctx.cwd, None, None, None, None, None, + &mut agent, &ctx, &ctx.cwd, None, None, None, None, None, None, ) .await { @@ -2075,6 +2116,10 @@ pub async fn run_prompt_task( ); } + let legacy_system = role_assignment + .map(|assignment| compose_role_section(&assignment)) + .map(|section| combine_optional_prompt(ctx.system_prompt.as_deref(), Some(§ion))) + .unwrap_or_else(|| ctx.system_prompt.clone()); crate::queue::format_prompt( b, &crate::queue::FormatPromptArgs { @@ -2084,7 +2129,7 @@ pub async fn run_prompt_task( profile_lookup: profile_lookup.as_ref(), has_system_prompt_support: agent.has_system_prompt_support(), base_prompt: ctx.base_prompt, - system_prompt: ctx.system_prompt.as_deref(), + system_prompt: legacy_system.as_deref(), team_instructions: ctx.team_instructions.as_deref(), agent_canvas: agent_canvas.as_deref(), }, @@ -2669,7 +2714,12 @@ pub(crate) async fn fetch_channel_info( /// /// Called at most once per new channel session; the result is cached in /// `SessionState::canvas_sections` and cleared on session invalidation. -async fn fetch_canvas_section(channel_id: Uuid, rest: &RestClient) -> Option { +async fn fetch_canvas_section( + channel_id: Uuid, + rest: &RestClient, + owner_pubkey: Option<&nostr::PublicKey>, + agent_pubkey: &nostr::PublicKey, +) -> Option { use nostr::{Alphabet, SingleLetterTag}; let h_tag = SingleLetterTag::lowercase(Alphabet::H); @@ -2717,7 +2767,36 @@ async fn fetch_canvas_section(channel_id: Uuid, rest: &RestClient) -> Option(events.first()?.clone()).ok()?; + if count_crew_blocks(&event.content) > 1 { + tracing::warn!( + target: "canvas::crew", + channel = %channel_id, + "multiple crew blocks found; using the first block" + ); + } + let role = owner_pubkey.and_then(|owner| { + match resolve_assignment( + &event.content, + &event.pubkey.to_hex(), + &owner.to_hex(), + &agent_pubkey.to_hex(), + ) { + Ok(role) => role, + Err(error) => { + tracing::warn!( + target: "canvas::crew", + channel = %channel_id, + agent = %agent_pubkey.to_hex(), + %error, + "malformed crew block — emitting no role section" + ); + None + } + } + }); + Some(CanvasSessionContext { rendered, role }) } /// Parse a canvas query response array and render a `[Channel Canvas]` section. @@ -9818,8 +9897,13 @@ mod tests { let ch = Uuid::new_v4(); let mut s = SessionState::default(); s.sessions.insert(ch, "sess".into()); - s.canvas_sections - .insert(ch, "[Channel Canvas]\nrev abc".into()); + s.canvas_sections.insert( + ch, + CanvasSessionContext { + rendered: "[Channel Canvas]\nrev abc".into(), + role: None, + }, + ); s.invalidate_channel(&ch); @@ -9832,8 +9916,20 @@ mod tests { let ch_a = Uuid::new_v4(); let ch_b = Uuid::new_v4(); let mut s = SessionState::default(); - s.canvas_sections.insert(ch_a, "canvas-a".into()); - s.canvas_sections.insert(ch_b, "canvas-b".into()); + s.canvas_sections.insert( + ch_a, + CanvasSessionContext { + rendered: "canvas-a".into(), + role: None, + }, + ); + s.canvas_sections.insert( + ch_b, + CanvasSessionContext { + rendered: "canvas-b".into(), + role: None, + }, + ); s.sessions.insert(ch_a, "sess-a".into()); s.invalidate_all(); @@ -9849,20 +9945,38 @@ mod tests { let mut s = SessionState::default(); s.sessions.insert(ch_a, "sess-a".into()); s.sessions.insert(ch_b, "sess-b".into()); - s.canvas_sections.insert(ch_a, "canvas-a".into()); - s.canvas_sections.insert(ch_b, "canvas-b".into()); + s.canvas_sections.insert( + ch_a, + CanvasSessionContext { + rendered: "canvas-a".into(), + role: None, + }, + ); + s.canvas_sections.insert( + ch_b, + CanvasSessionContext { + rendered: "canvas-b".into(), + role: None, + }, + ); s.invalidate_channel(&ch_a); assert!(!s.canvas_sections.contains_key(&ch_a)); - assert_eq!(s.canvas_sections.get(&ch_b).unwrap(), "canvas-b"); + assert_eq!(s.canvas_sections.get(&ch_b).unwrap().rendered, "canvas-b"); } #[test] fn test_has_channel_state_true_when_only_canvas_section_present() { let ch = Uuid::new_v4(); let mut s = SessionState::default(); - s.canvas_sections.insert(ch, "canvas".into()); + s.canvas_sections.insert( + ch, + CanvasSessionContext { + rendered: "canvas".into(), + role: None, + }, + ); assert!(s.has_channel_state(&ch)); } diff --git a/crates/buzz-core/Cargo.toml b/crates/buzz-core/Cargo.toml index c55225adf60..ac9bb43efeb 100644 --- a/crates/buzz-core/Cargo.toml +++ b/crates/buzz-core/Cargo.toml @@ -15,6 +15,7 @@ base64 = { workspace = true } nostr = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_yaml = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } diff --git a/crates/buzz-core/src/crew_role.rs b/crates/buzz-core/src/crew_role.rs new file mode 100644 index 00000000000..b61d4fa588e --- /dev/null +++ b/crates/buzz-core/src/crew_role.rs @@ -0,0 +1,252 @@ +//! Founder-signed, channel-scoped Crew role assignments. +//! +//! A role block is deliberately a small, forward-compatible YAML document +//! embedded in a channel's founder-authored canvas. The canvas event supplies +//! the channel scope; this module supplies parsing, validation, authority +//! filtering, and prompt framing without maintaining a role taxonomy. + +use std::collections::BTreeMap; + +use nostr::{FromBech32, PublicKey}; +use serde::Deserialize; +use thiserror::Error; + +const MAX_LABEL_LEN: usize = 128; + +/// A resolved role assignment for one agent in one channel canvas. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoleAssignment { + /// Founder-authored display label. + pub label: String, + /// Founder-authored allowed/not-allowed/redirect definition. + pub definition: String, +} + +/// Parsed content of a `crew` canvas block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanvasRoleBlock { + /// Agent pubkey to founder-authored label. + pub assignments: BTreeMap, + /// Case-folded role label to definition text. + pub definitions: BTreeMap, +} + +/// Errors that make a crew block fail closed. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RoleParseError { + /// The fenced block is not structurally complete. + #[error("malformed crew fenced block")] + MalformedFence, + /// The YAML document does not match the supported shape. + #[error("invalid crew YAML: {0}")] + InvalidYaml(String), + /// A role label violates the format-only constraints. + #[error("invalid crew role label: {0}")] + InvalidLabel(String), + /// A pubkey in the canvas or authority inputs is not a valid Nostr key. + #[error("invalid crew pubkey: {0}")] + InvalidPubkey(String), + /// An assignment points at a role without founder-authored meaning. + #[error("assignment for role `{0}` has no definition")] + MissingDefinition(String), +} + +#[derive(Debug, Deserialize)] +struct RawCanvasRoleBlock { + #[serde(default)] + assignments: BTreeMap, + #[serde(default)] + definitions: BTreeMap, +} + +/// Parse the first fenced ` ```crew ` block in canvas content. +/// +/// No block is represented by `Ok(None)`. A partial or malformed block is an +/// error so callers can warn and emit no role section rather than panicking. +/// If multiple blocks are present, the first block wins; callers should warn. +pub fn parse_canvas_assignments( + canvas_content: &str, +) -> Result, RoleParseError> { + let starts: Vec = canvas_content + .lines() + .enumerate() + .filter_map(|(index, line)| (line.trim() == "```crew").then_some(index)) + .collect(); + let Some(&start) = starts.first() else { + return Ok(None); + }; + let lines: Vec<&str> = canvas_content.lines().collect(); + let Some(end_offset) = lines[start + 1..] + .iter() + .position(|line| line.trim() == "```") + else { + return Err(RoleParseError::MalformedFence); + }; + let end = start + 1 + end_offset; + let yaml = lines[start + 1..end].join("\n"); + let raw: RawCanvasRoleBlock = + serde_yaml::from_str(&yaml).map_err(|e| RoleParseError::InvalidYaml(e.to_string()))?; + let mut assignments = BTreeMap::new(); + for (agent, label) in raw.assignments { + let agent = agent.trim().to_string(); + if agent.is_empty() { + return Err(RoleParseError::InvalidYaml( + "assignment agent key must not be empty".into(), + )); + } + let label = normalize_label(&label)?; + assignments.insert(agent, label); + } + let mut definitions = BTreeMap::new(); + for (label, definition) in raw.definitions { + let normalized = normalize_label(&label)?; + definitions.insert(normalized.to_ascii_lowercase(), definition); + } + Ok(Some(CanvasRoleBlock { + assignments, + definitions, + })) +} + +/// Count complete or partial ` ```crew ` fence openers in canvas content. +/// +/// The resolver intentionally uses the first block when this returns more +/// than one; callers can use the count to emit a warning. +pub fn count_crew_blocks(canvas_content: &str) -> usize { + canvas_content + .lines() + .filter(|line| line.trim() == "```crew") + .count() +} + +/// Remove the first fenced `crew` block while preserving surrounding prose. +/// +/// The block is machine configuration and should not be copied into rendered +/// canvas prose when the role/routing sections already carry its meaning. +pub fn strip_crew_block(canvas_content: &str) -> Result { + let Some(start) = canvas_content + .lines() + .position(|line| line.trim() == "```crew") + else { + return Ok(canvas_content.to_string()); + }; + let lines: Vec<&str> = canvas_content.lines().collect(); + let Some(end_offset) = lines[start + 1..] + .iter() + .position(|line| line.trim() == "```") + else { + return Err(RoleParseError::MalformedFence); + }; + let end = start + 1 + end_offset; + let mut kept = lines[..start].to_vec(); + kept.extend_from_slice(&lines[end + 1..]); + Ok(kept.join("\n")) +} + +/// Resolve one agent's assignment after checking canvas author authority. +/// +/// The caller must pass the author of the channel canvas event. The canvas +/// write path currently has no relay-side founder check, so this read-side +/// comparison is the enforcement point. +pub fn resolve_assignment( + canvas_content: &str, + canvas_author: &str, + owner_pubkey: &str, + agent_pubkey: &str, +) -> Result, RoleParseError> { + if !same_pubkey(canvas_author, owner_pubkey)? { + return Ok(None); + } + let Some(block) = parse_canvas_assignments(canvas_content)? else { + return Ok(None); + }; + let mut label = None; + for (agent, assignment_label) in &block.assignments { + if same_pubkey(agent, agent_pubkey)? { + label = Some(assignment_label.clone()); + break; + } + } + let Some(label) = label else { + return Ok(None); + }; + let Some(definition) = block.definitions.get(&label.to_ascii_lowercase()) else { + return Err(RoleParseError::MissingDefinition(label)); + }; + Ok(Some(RoleAssignment { + label, + definition: definition.clone(), + })) +} + +/// Compose fixed Crew framing around founder-authored assignment text. +pub fn compose_role_section(assignment: &RoleAssignment) -> String { + format!( + "## Role assignment (Crew)\n\n\ + You are assigned role: **{}**.\n\n\ + The founder-authored role definition below is authoritative:\n\ + {}\n\n\ + When a request is outside this definition:\n\ + 1. Do not silently execute it.\n\ + 2. Refuse briefly and redirect to the appropriate role or founder.\n\ + 3. Do not partially perform the off-role work.\n\n\ + MANDATORY declaration: The FIRST line of your first reply message for each turn MUST be exactly:\n\n\ + ROLE-CHECK: role={} decision=accept|refuse reason=\n\n\ + Never omit ROLE-CHECK, including for short answers.", + assignment.label, assignment.definition, assignment.label + ) +} + +fn normalize_label(raw: &str) -> Result { + let label = raw.trim(); + if label.is_empty() { + return Err(RoleParseError::InvalidLabel("label is empty".into())); + } + if label.len() > MAX_LABEL_LEN { + return Err(RoleParseError::InvalidLabel(format!( + "label exceeds {MAX_LABEL_LEN} bytes" + ))); + } + if label.lines().count() != 1 { + return Err(RoleParseError::InvalidLabel( + "label must be one line".into(), + )); + } + Ok(label.to_string()) +} + +fn same_pubkey(left: &str, right: &str) -> Result { + let left = parse_pubkey(left)?; + let right = parse_pubkey(right)?; + Ok(left == right) +} + +fn parse_pubkey(value: &str) -> Result { + let value = value.trim(); + PublicKey::from_hex(value) + .or_else(|_| PublicKey::from_bech32(value)) + .map_err(|_| RoleParseError::InvalidPubkey(value.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_top_level_keys_are_ignored() { + let parsed = parse_canvas_assignments( + "```crew\nassignments:\n agent: code\ndefinitions:\n code: text\nrouting: {}\n```", + ) + .unwrap() + .unwrap(); + assert_eq!(parsed.assignments["agent"], "code"); + } + + #[test] + fn malformed_block_is_an_error() { + assert_eq!( + parse_canvas_assignments("```crew\nassignments:\n"), + Err(RoleParseError::MalformedFence) + ); + } +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index eed8fab8c0c..9f2e2e719bc 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,8 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +/// Founder-signed, channel-scoped Crew role assignments. +pub mod crew_role; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, /// body parse/serialize, envelope build/validate, head selection. pub mod engram; diff --git a/crates/buzz-core/tests/crew_roles_contract.rs b/crates/buzz-core/tests/crew_roles_contract.rs new file mode 100644 index 00000000000..fea4fa79da7 --- /dev/null +++ b/crates/buzz-core/tests/crew_roles_contract.rs @@ -0,0 +1,123 @@ +use buzz_core::crew_role::{ + compose_role_section, parse_canvas_assignments, resolve_assignment, strip_crew_block, + RoleAssignment, RoleParseError, +}; +use nostr::{Keys, ToBech32}; + +#[test] +fn assignment_parse_and_resolve_is_agent_scoped_and_owner_signed() { + let owner = Keys::generate().public_key(); + let agent = Keys::generate().public_key(); + let owner_hex = owner.to_hex(); + let agent_hex = agent.to_hex(); + let agent_npub = agent.to_bech32().expect("npub"); + let canvas = r#" +introductory founder prose +```crew +assignments: + AGENT-NPUB: " Code Review " +definitions: + code review: | + allowed: inspect and review code + not-allowed: change production files + redirect: ask the implementation agent +future_key: + ignored: true +``` +closing founder prose +"#; + let canvas = canvas + .replace("AGENT-NPUB", &agent_npub) + .replace("owner", &owner_hex) + .replace("agent-one", &agent_hex); + + let assignment = resolve_assignment(&canvas, &owner_hex, &owner_hex, &agent_hex) + .expect("valid crew block") + .expect("assignment exists"); + + assert_eq!(assignment.label, "Code Review"); + assert_eq!( + assignment.definition, + "allowed: inspect and review code\nnot-allowed: change production files\nredirect: ask the implementation agent\n" + ); +} + +#[test] +fn non_owner_canvas_is_ignored_and_no_block_is_unchanged() { + let owner = "11".repeat(32); + let agent = "22".repeat(32); + let stranger = "33".repeat(32); + let canvas = + format!("```crew\nassignments:\n {agent}: code\ndefinitions:\n code: ignored\n```"); + assert!(resolve_assignment(&canvas, &stranger, &owner, &agent) + .expect("non-owner canvas should fail closed") + .is_none()); + assert!( + resolve_assignment("ordinary canvas prose", &owner, &owner, &agent) + .expect("canvas without crew block is valid") + .is_none() + ); +} + +#[test] +fn malformed_or_partial_block_fails_closed() { + let malformed = "```crew\nassignments:\n \"11\": [unterminated\n```"; + assert!(parse_canvas_assignments(malformed).is_err()); + assert!(resolve_assignment( + malformed, + &"11".repeat(32), + &"11".repeat(32), + &"22".repeat(32) + ) + .is_err()); +} + +#[test] +fn missing_definition_is_a_distinct_fail_closed_error() { + let owner = "11".repeat(32); + let agent = "22".repeat(32); + assert_eq!( + resolve_assignment( + &format!("```crew\nassignments:\n {agent}: reviewer\n```"), + &owner, + &owner, + &agent, + ), + Err(RoleParseError::MissingDefinition("reviewer".into())) + ); +} + +#[test] +fn invalid_pubkey_is_a_fail_closed_error() { + assert!(matches!( + resolve_assignment( + "```crew\nassignments:\n not-a-key: reviewer\ndefinitions:\n reviewer: review\n```", + &"11".repeat(32), + &"11".repeat(32), + &"22".repeat(32), + ), + Err(RoleParseError::InvalidPubkey(_)) + )); +} + +#[test] +fn canvas_rendering_strips_crew_block_and_preserves_prose() { + let rendered = strip_crew_block( + "Founder intro.\n\n```crew\nassignments: {}\ndefinitions: {}\n```\n\nClosing prose.", + ) + .expect("valid fence"); + assert_eq!(rendered, "Founder intro.\n\n\nClosing prose."); + assert!(!rendered.contains("assignments:")); +} + +#[test] +fn definition_text_reaches_prompt_verbatim() { + let assignment = RoleAssignment { + label: "arbitrary founder label".into(), + definition: "allowed: do X\nnot-allowed: do Y\nredirect: ask Z".into(), + }; + let section = compose_role_section(&assignment); + assert!(section.contains(&assignment.label)); + assert!(section.contains(&assignment.definition)); + assert!(section.contains("ROLE-CHECK:")); +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index aea28d8e367..c99b8da0648 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1051,6 +1051,7 @@ dependencies = [ "rand 0.10.2", "serde", "serde_json", + "serde_yaml", "sha2 0.11.0", "subtle", "thiserror 2.0.18", diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 26c3c383230..06c2c435661 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -1,10 +1,8 @@ -use std::collections::{BTreeMap, HashSet}; - +use super::agent_model_process::run_agent_models_command; use nostr::Keys; use serde::Deserialize; +use std::collections::{BTreeMap, HashSet}; use tauri::{AppHandle, State}; - -use super::agent_model_process::run_agent_models_command; // The map-only lookup is reached solely from the base-URL helpers that exist for // their unit tests; discovery itself always goes through the process-env variant. #[cfg(test)] @@ -13,7 +11,6 @@ use super::agent_models_env::{ effective_discovery_provider, env_or_process_value, redaction_env_with_value, DiscoveryProvider, }; use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRollback}; - use crate::{ app_state::AppState, managed_agents::{ @@ -27,7 +24,6 @@ use crate::{ relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, }; - /// Query available models from an agent via `buzz-acp models --json`. /// /// Spawns a short-lived subprocess (no relay connection needed). The subprocess @@ -56,15 +52,12 @@ pub async fn get_agent_models( for pubkey in &exited_pubkeys { state.clear_agent_session_caches(pubkey); } - let record = records .iter() .find(|r| r.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; - let resolved = resolve_command(&record.acp_command) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; - // Resolve the effective harness from the linked persona (mirrors spawn), // so model discovery runs against the persona's current harness, not the // frozen record snapshot. An explicit per-agent override wins. @@ -805,9 +798,6 @@ pub async fn update_managed_agent( if let Some(p) = hermes_profile_update { record.hermes_profile = p; } - // mcp_command is intentionally not applied here — the effective MCP - // command is always catalog-derived (known_acp_runtime at spawn time) - // and the per-record field is never read by the runtime. if let Some(env_vars) = input.env_vars { crate::managed_agents::validate_user_env_keys(&env_vars)?; record.env_vars = env_vars; @@ -860,11 +850,7 @@ pub async fn update_managed_agent( .find(|r| r.pubkey == input.pubkey) .ok_or_else(|| format!("agent {} not found", input.pubkey))?; - // Publish the edit to the relay. After-save, inside the lock, before - // any .await. The retention upsert hashes the opt-IN projection, so an - // update that touched only runtime/local fields is a no-op publish. super::agents::retain_managed_agent_pending(&app, &state, record); - let sync_params = if name_changed { let agent_keys = Keys::parse(&record.private_key_nsec) .map_err(|e| format!("failed to parse agent keys: {e}"))?; @@ -929,7 +915,6 @@ pub async fn update_managed_agent( )); } } - Ok(UpdateManagedAgentResponse { agent: summary, profile_sync_error: None, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 3d0fcf9d85d..eaabc90df9a 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1,6 +1,3 @@ -use nostr::{Keys, ToBech32}; -use tauri::{AppHandle, State}; - use crate::{ app_state::AppState, managed_agents::{ @@ -17,14 +14,14 @@ use crate::{ relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, }; - +use nostr::{Keys, ToBech32}; +use tauri::{AppHandle, State}; /// Read the workspace owner pubkey without holding the lock. Used to populate `BUZZ_ACP_AGENT_OWNER` /// as a fallback for legacy agent records that have no NIP-OA `auth_tag`. pub(super) fn workspace_owner_hex(state: &AppState) -> Result { let keys = state.keys.lock().map_err(|e| e.to_string())?; Ok(keys.public_key().to_hex()) } - /// Retain a freshly authored managed-agent event in the local store, flagged /// for relay sync. MUST be called inside the `managed_agents_store_lock`-held /// body after `save_managed_agents`, NEVER across an `.await`: it acquires diff --git a/desktop/src-tauri/src/commands/assignment_publish.rs b/desktop/src-tauri/src/commands/assignment_publish.rs new file mode 100644 index 00000000000..d569c6d9ba3 --- /dev/null +++ b/desktop/src-tauri/src/commands/assignment_publish.rs @@ -0,0 +1,59 @@ +use crate::{app_state::AppState, events, relay::submit_event}; + +pub(crate) fn assignment_announcement_content( + agent_pubkey: &str, + label: &str, + definition: &str, +) -> String { + format!( + "AGENT-WORKING-AGREEMENT: assigned {agent_pubkey} to role `{label}` in this channel.\n\n{definition}" + ) +} + +/// Publish a durable assignment announcement in the target channel. +pub(crate) async fn publish_assignment_announcement( + state: &AppState, + channel_id: &str, + content: &str, +) -> Result { + let channel = parse_channel_id(channel_id)?; + let relay_base = crate::relay::relay_api_base_url_with_override(state); + let builder = events::build_message( + channel, + content.trim(), + None, + &[], + &[], + &[], + &[], + &[], + &relay_base, + )?; + submit_event(builder, state) + .await + .map(|result| result.event_id) + .map_err(|error| format!("assignment announcement publish failed: {error}")) +} + +fn parse_channel_id(channel_id: &str) -> Result { + uuid::Uuid::parse_str(channel_id).map_err(|_| format!("invalid channel UUID: {channel_id}")) +} + +#[cfg(test)] +mod tests { + use super::{assignment_announcement_content, parse_channel_id}; + + #[test] + fn assignment_announcement_contains_assignment_details() { + let content = assignment_announcement_content("agent", "reviewer", "Review code."); + assert!(content.contains("AGENT-WORKING-AGREEMENT")); + assert!(content.contains("reviewer")); + assert!(content.contains("Review code.")); + } + + #[test] + fn assignment_announcement_rejects_invalid_channel_before_publish() { + let error = parse_channel_id("not-a-uuid").expect_err("invalid channel must propagate"); + assert!(error.contains("invalid channel UUID")); + } +} diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs index 191fafe60d8..0f8dad05453 100644 --- a/desktop/src-tauri/src/commands/canvas.rs +++ b/desktop/src-tauri/src/commands/canvas.rs @@ -6,6 +6,67 @@ use crate::{ relay::{query_relay, submit_event}, }; +/// Update one founder-authored assignment while preserving all other canvas +/// prose and unknown Crew keys for forward compatibility. +pub(crate) fn update_canvas_crew_assignment( + content: &str, + agent_pubkey: &str, + label: &str, + definition: &str, +) -> Result { + let (prefix, yaml, suffix) = match fenced_crew_block(content) { + Some(parts) => parts, + None => ( + content.to_string(), + "assignments: {}\ndefinitions: {}\n".to_string(), + String::new(), + ), + }; + let mut document: serde_yaml::Value = + serde_yaml::from_str(&yaml).map_err(|e| format!("invalid crew YAML: {e}"))?; + let root = document + .as_mapping_mut() + .ok_or_else(|| "crew YAML must be a mapping".to_string())?; + { + let assignments = root + .entry(serde_yaml::Value::String("assignments".into())) + .or_insert_with(|| serde_yaml::Value::Mapping(Default::default())) + .as_mapping_mut() + .ok_or_else(|| "crew assignments must be a mapping".to_string())?; + assignments.insert( + serde_yaml::Value::String(agent_pubkey.trim().to_string()), + serde_yaml::Value::String(label.trim().to_string()), + ); + } + { + let definitions = root + .entry(serde_yaml::Value::String("definitions".into())) + .or_insert_with(|| serde_yaml::Value::Mapping(Default::default())) + .as_mapping_mut() + .ok_or_else(|| "crew definitions must be a mapping".to_string())?; + definitions.insert( + serde_yaml::Value::String(label.trim().to_string()), + serde_yaml::Value::String(definition.to_string()), + ); + } + let yaml = + serde_yaml::to_string(&document).map_err(|e| format!("serialize crew YAML: {e}"))?; + let updated = format!("{prefix}```crew\n{yaml}```{suffix}"); + buzz_core_pkg::crew_role::parse_canvas_assignments(&updated).map_err(|e| e.to_string())?; + Ok(updated) +} + +fn fenced_crew_block(content: &str) -> Option<(String, String, String)> { + let start = content.find("```crew")?; + let body_start = content[start..].find('\n').map(|offset| start + offset + 1)?; + let end = content[body_start..].find("```").map(|offset| body_start + offset)?; + Some(( + content[..start].to_string(), + content[body_start..end].to_string(), + content[end + 3..].to_string(), + )) +} + /// Read the most recent canvas event (kind:40100) for a channel. #[tauri::command] pub async fn get_canvas( @@ -58,3 +119,64 @@ pub async fn set_canvas( "event_id": result.event_id, })) } + +/// Assign one agent a founder-authored role in a channel's canvas. +#[tauri::command] +pub async fn assign_channel_agent_role( + channel_id: String, + agent_pubkey: String, + label: String, + definition: String, + state: State<'_, AppState>, +) -> Result { + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": 1 + })], + ) + .await?; + let current = events + .first() + .map(|event| event.content.as_str()) + .unwrap_or(""); + let updated = + update_canvas_crew_assignment(current, &agent_pubkey, &label, &definition)?; + let uuid = uuid::Uuid::parse_str(&channel_id) + .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; + let canvas_result = submit_event(events::build_set_canvas(uuid, &updated)?, &state).await?; + let announcement = + crate::commands::assignment_announcement_content(&agent_pubkey, label.trim(), &definition); + let announcement_event_id = + crate::commands::publish_assignment_announcement(&state, &channel_id, &announcement) + .await?; + + Ok(serde_json::json!({ + "ok": true, + "canvas_event_id": canvas_result.event_id, + "announcement_event_id": announcement_event_id, + })) +} + +#[cfg(test)] +mod tests { + use super::update_canvas_crew_assignment; + + #[test] + fn crew_assignment_update_preserves_canvas_prose() { + let original = "Founder guidance.\n\n```crew\nassignments:\n old: Research\ndefinitions:\n Research: Read first.\nrouting:\n review: Research\n```\n\nClosing notes."; + let updated = update_canvas_crew_assignment( + original, + "agent", + "Code Review", + "Allowed: inspect code.\nNot allowed: merge.", + ) + .expect("valid crew block"); + assert!(updated.starts_with("Founder guidance.\n\n")); + assert!(updated.ends_with("\n\nClosing notes.")); + assert!(updated.contains("Code Review")); + assert!(updated.contains("routing:")); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 2745b6ddb11..17da3852446 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ mod agent_models_env; mod agent_providers; mod agent_settings; mod agent_update_rollback; +mod assignment_publish; mod agents; mod canvas; mod channel_templates; @@ -91,6 +92,9 @@ pub use agent_metric_archive::*; pub use agent_models::*; pub use agent_providers::*; pub use agent_settings::*; +pub(crate) use assignment_publish::{ + assignment_announcement_content, publish_assignment_announcement, +}; pub use agents::*; pub use canvas::*; pub use channel_templates::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 00fff396342..d849cf95c30 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -729,6 +729,7 @@ pub fn run() { leave_channel, get_canvas, set_canvas, + assign_channel_agent_role, get_feed, search_messages, send_channel_message, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index 566e0db25f3..a108e44cb8d 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -25,8 +25,8 @@ fn minimal_record() -> ManagedAgentRecord { agent_command_override: Some("goose-override".to_string()), // MUST NOT appear agent_args: vec!["--arg".to_string()], // MUST NOT appear in snapshot hermes_profile: Some("scout".to_string()), // MUST NOT appear in snapshot - mcp_command: "mcp-server".to_string(), // MUST NOT appear in snapshot - turn_timeout_seconds: 120, // deprecated, MUST NOT appear + mcp_command: "mcp-server".to_string(), // MUST NOT appear in snapshot + turn_timeout_seconds: 120, // deprecated, MUST NOT appear idle_timeout_seconds: Some(30), max_turn_duration_seconds: Some(600), parallelism: 2, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 1e840391526..799c552ed03 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -1,5 +1,3 @@ -use std::path::PathBuf; - use super::overrides::{divergent_agent_command_override, update_time_agent_command_override}; use super::{ apply_agent_command_update, classify_runtime, codex_adapter_availability, @@ -11,6 +9,7 @@ use super::{ GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; +use std::path::PathBuf; #[test] fn resolves_known_avatar_for_bare_command() { diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 7d4ddc5130a..33eba3ae50c 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -37,7 +37,6 @@ //! separately because it is not part of the process env — the harness reads //! it at startup. We do not evaluate it here; it is exposed for future //! UI display only. - use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ec4970e0c92..67083ddc440 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -6,7 +6,6 @@ //! All converters here are I/O-free and deterministic — they take owned //! or borrowed events and return models. This makes them trivially //! testable with hand-crafted events (see the `tests` module below). - use std::collections::{BTreeSet, HashMap}; use nostr::{Event, ToBech32}; @@ -441,7 +440,8 @@ pub fn search_response_from_events(events: &[Event]) -> SearchResponse { /// Convert kind:10100 agent profile events to the agent discovery format. /// /// Returns a JSON array of `{pubkey, name, ...}` objects parsed from each -/// event's content. +/// event's content. Unknown tags are ignored — stock +/// consumers keep working (issue #116 stock safety). pub fn agents_from_events(events: &[Event]) -> Value { let arr: Vec = events .iter() diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 5c3b68868c2..e687d0ed9c3 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -101,7 +101,6 @@ import { runtimeDropdownAction, usePendingHarnessSelection, } from "./addCustomHarness"; - export function AgentInstanceEditDialog({ agent, initialFocus, @@ -124,7 +123,6 @@ export function AgentInstanceEditDialog({ const runtimesQuery = useAcpRuntimesQuery({ enabled: open }); const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null); const runtimes = runtimesQuery.data ?? []; - const [name, setName] = React.useState(agent.name); const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false); const aiDefaultsTriggerRef = React.useRef(null); @@ -175,14 +173,11 @@ export function AgentInstanceEditDialog({ React.useState(false); const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false); const shouldReduceMotion = useReducedMotion(); - // Runtime selector: defaults to "custom" until the dialog opens and the // catalog loads. The open-effect re-derives the correct id from the catalog. const [selectedRuntimeId, setSelectedRuntimeId] = React.useState("custom"); - // Tracks whether the user has made an in-dialog runtime selection. const runtimeTouched = React.useRef(false); - // Reset form state only when the dialog opens or when switching to a different agent. // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — including agent fields would re-fire on every 5s poll and wipe edits React.useEffect(() => { @@ -218,7 +213,6 @@ export function AgentInstanceEditDialog({ updateMutation.reset(); } }, [open, agent.pubkey]); - // Re-derive the runtime id when the catalog loads. React.useEffect(() => { if (!open || runtimeTouched.current || runtimes.length === 0) { @@ -231,13 +225,11 @@ export function AgentInstanceEditDialog({ setSelectedRuntimeId(matched.id); } }, [open, runtimes, agent.agentCommand]); - // Build the sorted runtime catalog for the dropdown. const sortedRuntimes = React.useMemo( () => sortPersonaRuntimes(runtimes), [runtimes], ); - const selectedRuntime = React.useMemo( () => runtimes.find((r) => r.id === selectedRuntimeId), [runtimes, selectedRuntimeId], diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 51297b915b0..a47cf7bd16e 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -22,8 +22,10 @@ import { unarchiveChannel, updateChannel, } from "@/shared/api/tauri"; +import { assignChannelAgentRole } from "@/shared/api/assignment"; import type { AddChannelMembersInput, + AssignChannelAgentRoleInput, Channel, ChannelDetail, CreateChannelInput, @@ -701,3 +703,22 @@ export function useSetCanvasMutation(channelId: string | null) { }, }); } + +export function useAssignChannelAgentRoleMutation(channelId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: Omit) => { + if (!channelId) { + return Promise.reject(new Error("No channel selected")); + } + return assignChannelAgentRole({ ...input, channelId }); + }, + onSuccess: () => { + if (channelId) { + void queryClient.invalidateQueries({ + queryKey: ["canvas", channelId], + }); + } + }, + }); +} diff --git a/desktop/src/features/channels/ui/ChannelCanvas.tsx b/desktop/src/features/channels/ui/ChannelCanvas.tsx index 785be39227b..1e32349e44d 100644 --- a/desktop/src/features/channels/ui/ChannelCanvas.tsx +++ b/desktop/src/features/channels/ui/ChannelCanvas.tsx @@ -2,6 +2,7 @@ import { Pencil, Save, X } from "lucide-react"; import * as React from "react"; import { + useAssignChannelAgentRoleMutation, useCanvasQuery, useSetCanvasMutation, } from "@/features/channels/hooks"; @@ -27,6 +28,7 @@ export function ChannelCanvas({ }: ChannelCanvasProps) { const canvasQuery = useCanvasQuery(channelId, channelId !== null); const setCanvasMutation = useSetCanvasMutation(channelId); + const assignRoleMutation = useAssignChannelAgentRoleMutation(channelId); const { channels } = useChannelNavigation(); const channelNames = React.useMemo( () => channels.filter((c) => c.channelType !== "dm").map((c) => c.name), @@ -34,6 +36,9 @@ export function ChannelCanvas({ ); const [isEditing, setIsEditing] = React.useState(false); const [draft, setDraft] = React.useState(""); + const [assignmentAgent, setAssignmentAgent] = React.useState(""); + const [assignmentLabel, setAssignmentLabel] = React.useState(""); + const [assignmentDefinition, setAssignmentDefinition] = React.useState(""); const canvasContent = canvasQuery.data?.content ?? null; // Defer the single large Markdown parse so opening the canvas commits the @@ -135,16 +140,74 @@ export function ChannelCanvas({

)} {canEdit && !isArchived ? ( - +
+ +
+

Assign an agent role

+ setAssignmentAgent(event.target.value)} + placeholder="Agent pubkey (hex or npub)" + value={assignmentAgent} + /> + setAssignmentLabel(event.target.value)} + placeholder="Role label" + value={assignmentLabel} + /> +