Skip to main content

lean_ctx_server/tools/
ctx_agent.rs

1// SPDX-License-Identifier: Apache-2.0
2use lean_ctx::core::a2a::message::{MessagePriority, PrivacyLevel};
3use lean_ctx::core::a2a::task::TaskStore;
4use lean_ctx::core::agents::{
5    AgentDiary, AgentRegistry, AgentStatus, DiaryEntryType, ScratchpadEntry,
6};
7use lean_ctx::core::ocla::capsule::global_capsule_store;
8use lean_ctx::core::ocla::registry::OclaRegistry;
9use lean_ctx::core::ocla::types::{
10    AGENT_ENVELOPE_SCHEMA_VERSION, AgentEnvelope, AgentMessageRequest, OclaRequestContext,
11};
12
13#[path = "ctx_agent_control.rs"]
14mod control;
15#[path = "ctx_agent_lease.rs"]
16mod lease;
17use control::{control_status, lease_resource};
18#[cfg(test)]
19use lease::LeaseSettle;
20
21fn presence_ttl_hours() -> u64 {
22    lean_ctx::core::config::Config::load()
23        .agents
24        .presence_ttl_hours
25}
26
27fn scratchpad_default_ttl_hours() -> u64 {
28    lean_ctx::core::config::Config::load()
29        .agents
30        .scratchpad_default_ttl_hours
31}
32
33/// Every action [`handle`] dispatches, one entry each. The MCP schema's
34/// `action` enum and the unknown-action reply are both built from this list.
35pub(crate) const ACTIONS: &[&str] = &[
36    "register",
37    "list",
38    "post",
39    "read",
40    "wait",
41    "status",
42    "info",
43    "handoff",
44    "sync",
45    "claim",
46    "release",
47    "brief",
48    "return",
49    "export",
50    "diary",
51    "recall_diary",
52    "diaries",
53    "share_knowledge",
54    "receive_knowledge",
55    "poll_events",
56    "control_status",
57    "lease_acquire",
58    "lease_renew",
59    "lease_release",
60    "send",
61    "reply",
62    "inbox",
63];
64
65/// Arguments of the Hive message actions (`send`, `reply`, `inbox`).
66#[derive(Debug, Clone, Default)]
67pub struct MessageArgs {
68    pub message_type: Option<String>,
69    pub text: Option<String>,
70    pub to: Option<String>,
71    pub reply_to: Option<String>,
72    pub references: Vec<String>,
73    /// reply to a handoff: take it (`true`) or decline it.
74    pub accept: Option<bool>,
75}
76
77impl MessageArgs {
78    fn untyped(text: Option<&str>, category: Option<&str>, to: Option<&str>) -> Self {
79        Self {
80            message_type: category.map(str::to_string),
81            text: text.map(str::to_string),
82            to: to.map(str::to_string),
83            ..Self::default()
84        }
85    }
86}
87
88/// `send`: a typed message to the vault bus of the checkout. `reply`:
89/// answers a received message in its thread (type defaults to `answer`).
90/// `inbox`: the unread messages, Hive deliveries fenced as foreign data
91/// ([`lean_ctx_hive::bus::messages_v2`]).
92pub fn handle_message(
93    action: &str,
94    args: &MessageArgs,
95    project_root: &str,
96    current_agent_id: Option<&str>,
97) -> String {
98    use lean_ctx_hive::bus::messages_v2::{Extras, send};
99    let Some(agent) = current_agent_id else {
100        return "Error: agent must be registered first (use action=register)".to_string();
101    };
102    if action == "inbox" {
103        return match read_messages_as(agent, project_root, true) {
104            Ok(text) | Err(text) => text,
105        };
106    }
107    let Some(text) = args.text.as_deref() else {
108        return format!("Error: message is required for {action}");
109    };
110    let reply_to = args.reply_to.as_deref();
111    if action == "reply" && reply_to.is_none() {
112        return "Error: reply_to (the msg_… id of the message answered) is required".to_string();
113    }
114    let message_type = args
115        .message_type
116        .as_deref()
117        .or((action == "reply").then_some("answer"));
118    let extras = Extras {
119        to_worker: args.to.as_deref(),
120        reply_to,
121        accept: args.accept,
122        resource: args.references.first().map(String::as_str),
123        refs: &args.references,
124    };
125    match send(project_root, agent, message_type, text, extras) {
126        Ok(id) => format!(
127            "Queued [{}] {id}; the Hive relay sends it to the vault's agents",
128            message_type.unwrap_or("?")
129        ),
130        Err(error) => format!("Error: {error}"),
131    }
132}
133
134/// `action=read` with a typed outcome: `Ok` is the reply, `Err` is the text
135/// of a failed read — the same text [`handle`] answers. `read` is the one
136/// action of this tool that declares every source
137/// (`CtxAgentTool::publishes_every_source`), so its failure must reach the
138/// registered tool as a failure and never as a reply that could be recorded
139/// as a delivery of the sources captured before it failed.
140pub fn handle_read(
141    message: Option<&str>,
142    current_agent_id: Option<&str>,
143    project_root: &str,
144) -> Result<String, String> {
145    // Same admission as `handle`: unadmitted caller text refuses the call.
146    if let Some(text) = message
147        && let Err(withheld) = lean_ctx::core::context_admission::carrier::admit_for_store(text)
148    {
149        return Err(format!("Error: {}", withheld.message("agent message")));
150    }
151    read_messages(current_agent_id, project_root)
152}
153
154/// The unread messages of `current_agent_id` in `project_root`.
155fn read_messages(current_agent_id: Option<&str>, project_root: &str) -> Result<String, String> {
156    let Some(agent_id) = current_agent_id else {
157        return Err("Error: agent must be registered first (use action=register)".to_string());
158    };
159    let mut text = read_messages_as(agent_id, project_root, true)?;
160    // What other agents declared in the linked emspace space, beside the bus.
161    if let Some(sessions) =
162        lean_ctx::core::providers::emspace::admitted_sessions_block(project_root)
163    {
164        text.push_str(&sessions);
165    }
166    Ok(text)
167}
168
169/// The line `read`/`wait` print once after an expired registration was renewed.
170fn renewed_line(agent_id: &str) -> String {
171    format!(
172        "Note: the registration of {agent_id} had expired and was renewed; retry a claim refused meanwhile.\n"
173    )
174}
175
176/// One `read` answers at most this much message text (at least one
177/// message); the rest stays unread and the answer says how many. Hosts
178/// archive or cut longer tool answers, and the cut messages were lost.
179pub(crate) const READ_PAGE_BYTES: usize = 6_000;
180
181/// One unread message as `read` shows it.
182fn render_message(m: &ScratchpadEntry, project_root: &str) -> String {
183    let age = (chrono::Utc::now() - m.timestamp).num_minutes();
184    // A new delivery: re-authorized under the current policy. One from
185    // another instance carries its sender and kind inside the fence.
186    if lean_ctx_hive::bus::relay_v2::is_inbound(m) {
187        return format!(
188            "  [hive] ({age}m ago):\n{}\n",
189            m.served_message(Some(project_root))
190        );
191    }
192    format!(
193        "  [{}] from {} ({}m ago): {}\n",
194        m.category,
195        m.from_agent,
196        age,
197        m.served_message(Some(project_root))
198    )
199}
200
201/// The unread messages of `agent_id`, oldest first and at most
202/// `READ_PAGE_BYTES` per call; only the ones shown are marked read.
203/// `own_process`: the call comes from the agent's own session, which renews
204/// its lease (and revives an expired one); `lean-ctx agent wait` reads for
205/// it from another process.
206pub fn read_messages_as(
207    agent_id: &str,
208    project_root: &str,
209    own_process: bool,
210) -> Result<String, String> {
211    let (shown, left) = match AgentRegistry::mutate_locked(|registry| {
212        if own_process {
213            registry.renew_worker(agent_id)?;
214        }
215        registry.read_unread_page(agent_id, project_root, READ_PAGE_BYTES, |m| {
216            render_message(m, project_root)
217        })
218    }) {
219        Ok((_, Ok(page))) => page,
220        Ok((_, Err(error))) => {
221            tracing::warn!("lean-ctx: message read rejected: {error}");
222            return Err(format!("Error: {error}"));
223        }
224        Err(e) => {
225            tracing::warn!(
226                "lean-ctx: failed to persist agent registry (messages may reappear): {e}"
227            );
228            return Err("Error: failed to read messages".to_string());
229        }
230    };
231
232    let mut renewed = if lean_ctx::core::agents::take_renewed(agent_id) {
233        renewed_line(agent_id)
234    } else {
235        String::new()
236    };
237    // An MCP server older than the installed binary misses its fixes.
238    if own_process && let Some(note) = lean_ctx::core::binary_identity::replaced_note() {
239        renewed.insert_str(0, &note);
240    }
241    if shown.is_empty() {
242        return Ok(format!("{renewed}No new messages."));
243    }
244
245    let mut out = format!("{renewed}New messages ({}):\n", shown.len());
246    for line in &shown {
247        out.push_str(line);
248    }
249    if left > 0 {
250        out.push_str(&format!(
251            "{left} more unread (still unread): read again for the next ones.\n"
252        ));
253    }
254    Ok(out)
255}
256
257/// `wait` without `timeout_secs`, and its ceiling.
258pub const WAIT_DEFAULT_SECS: u64 = 600;
259pub const WAIT_MAX_SECS: u64 = 1800;
260
261/// The stable reply of a `wait` that saw no message.
262pub fn wait_timeout_line(secs: u64) -> String {
263    format!("No new messages within {secs}s.")
264}
265
266/// `action=wait`: block until a message for `agent_id` (to it or broadcast,
267/// not its own) is waiting in `project_root`, then answer exactly like
268/// `read`; `Ok(None)` after `timeout_secs` (default 600, at most 1800).
269/// `own_process` as in [`read_messages_as`]: the agent's own session keeps
270/// its lease alive while it waits.
271pub fn wait_messages(
272    agent_id: &str,
273    project_root: &str,
274    timeout_secs: Option<u64>,
275    own_process: bool,
276) -> Result<Option<String>, String> {
277    let secs = timeout_secs.unwrap_or(WAIT_DEFAULT_SECS).min(WAIT_MAX_SECS);
278    if own_process {
279        // Fail fast on an unknown or finished agent instead of waiting.
280        AgentRegistry::renew_worker_persistent(agent_id).map_err(|e| format!("Error: {e}"))?;
281    }
282    let arrived = AgentRegistry::wait_for_unread(
283        agent_id,
284        project_root,
285        std::time::Duration::from_secs(secs),
286        || {
287            if own_process && let Err(e) = AgentRegistry::renew_worker_persistent(agent_id) {
288                tracing::debug!("lean-ctx: wait keepalive for {agent_id}: {e}");
289            }
290        },
291    );
292    if arrived {
293        return read_messages_as(agent_id, project_root, own_process).map(Some);
294    }
295    lean_ctx::core::agents::friction::record_for(
296        lean_ctx::core::agents::friction::WAIT_TIMEOUT,
297        Some(agent_id),
298        Some(project_root),
299    );
300    Ok(None)
301}
302
303/// The text of `wait` for the MCP tool: the messages, or the timeout line
304/// (after the renewal note, when there is one).
305pub fn handle_wait(
306    current_agent_id: Option<&str>,
307    project_root: &str,
308    timeout_secs: Option<u64>,
309) -> Result<String, String> {
310    let Some(agent_id) = current_agent_id else {
311        return Err("Error: agent must be registered first (use action=register)".to_string());
312    };
313    let secs = timeout_secs.unwrap_or(WAIT_DEFAULT_SECS).min(WAIT_MAX_SECS);
314    Ok(
315        match wait_messages(agent_id, project_root, timeout_secs, true)? {
316            Some(text) => text,
317            None if lean_ctx::core::agents::take_renewed(agent_id) => {
318                format!("{}{}", renewed_line(agent_id), wait_timeout_line(secs))
319            }
320            None => wait_timeout_line(secs),
321        },
322    )
323}
324
325/// A `ctx_agent` call. What Hive answered to this agent's earlier lease
326/// requests (asked off the tool call) comes in front of the answer, except
327/// for `read`, whose answer is a delivery of its sources only.
328#[allow(clippy::too_many_arguments)]
329pub fn handle(
330    action: &str,
331    agent_type: Option<&str>,
332    role: Option<&str>,
333    durable_identity_id: Option<&str>,
334    project_root: &str,
335    current_agent_id: Option<&str>,
336    message: Option<&str>,
337    category: Option<&str>,
338    to_agent: Option<&str>,
339    status: Option<&str>,
340    privacy: Option<&str>,
341    priority: Option<&str>,
342    ttl_hours: Option<u64>,
343    format: Option<&str>,
344    write: bool,
345    filename: Option<&str>,
346    request_id: Option<&str>,
347) -> String {
348    let earlier = current_agent_id.filter(|_| action != "read").map(|agent| {
349        lean_ctx_hive::background::earlier(&lean_ctx_hive::bus::leases::caller(agent))
350    });
351    let answer = handle_action(
352        action,
353        agent_type,
354        role,
355        durable_identity_id,
356        project_root,
357        current_agent_id,
358        message,
359        category,
360        to_agent,
361        status,
362        privacy,
363        priority,
364        ttl_hours,
365        format,
366        write,
367        filename,
368        request_id,
369    );
370    match earlier {
371        Some(earlier) => earlier.present(answer),
372        None => answer,
373    }
374}
375
376#[allow(clippy::too_many_arguments)]
377fn handle_action(
378    action: &str,
379    agent_type: Option<&str>,
380    role: Option<&str>,
381    durable_identity_id: Option<&str>,
382    project_root: &str,
383    current_agent_id: Option<&str>,
384    message: Option<&str>,
385    category: Option<&str>,
386    to_agent: Option<&str>,
387    status: Option<&str>,
388    privacy: Option<&str>,
389    priority: Option<&str>,
390    _ttl_hours: Option<u64>,
391    format: Option<&str>,
392    write: bool,
393    filename: Option<&str>,
394    request_id: Option<&str>,
395) -> String {
396    // The caller's text is admitted once, before any action stores, routes,
397    // derives from or echoes it (post, handoff, status, diary, knowledge,
398    // brief). Unadmitted input is never echoed back: a refusal names the typed
399    // reason only. Allowed text is unchanged byte for byte.
400    let admitted_message = match message
401        .map(lean_ctx::core::context_admission::carrier::admit_for_store)
402        .transpose()
403    {
404        Ok(admitted) => admitted,
405        Err(withheld) => return format!("Error: {}", withheld.message("agent message")),
406    };
407    let message = admitted_message.as_deref();
408    match action {
409        "register" => {
410            let atype = agent_type.unwrap_or("unknown");
411            match AgentRegistry::mutate_locked(|registry| {
412                registry.cleanup_stale(presence_ttl_hours());
413                registry.register(atype, role, project_root, durable_identity_id)
414            })
415            .and_then(|(_, agent_id)| agent_id)
416            {
417                Ok(agent_id) => {
418                    // The relay announces the agent (and its role) now, not
419                    // on its next tick: a claim right after register finds it.
420                    lean_ctx_hive::bus::presence::request_announce();
421                    // The note goes last: callers take the id from the
422                    // text after the first ':'.
423                    format!(
424                        "Agent registered: {agent_id} (type: {atype}, role: {}){}",
425                        role.unwrap_or("none"),
426                        lean_ctx::core::binary_identity::replaced_note()
427                            .map(|note| format!("\n{}", note.trim_end()))
428                            .unwrap_or_default()
429                    )
430                }
431                Err(e) => format!("Registration failed: {e}"),
432            }
433        }
434
435        "list" => {
436            let registry = match AgentRegistry::mutate_locked(|registry| {
437                registry.cleanup_stale(presence_ttl_hours());
438            }) {
439                Ok((registry, ())) => registry,
440                Err(e) => {
441                    tracing::warn!("lean-ctx: failed to persist agent registry: {e}");
442                    AgentRegistry::load_or_create()
443                }
444            };
445
446            let agents = registry.list_active(Some(project_root));
447            let sessions =
448                lean_ctx::core::providers::emspace::admitted_sessions_block(project_root)
449                    .unwrap_or_default();
450            if agents.is_empty() {
451                return format!("No active agents for this project.{sessions}");
452            }
453
454            // V5.3: sessions are listed under the durable worker they attach
455            // to; without an enrolled instance workers have no `wkr_` id.
456            let store = lean_ctx_hive::HiveStore::open().ok();
457            let instance = store
458                .as_ref()
459                .and_then(|s| s.instance().ok().flatten())
460                .map(|record| record.instance_id);
461            let book = match (&store, &instance) {
462                (Some(store), Some(id)) => {
463                    lean_ctx_hive::workers::attach::list(store, id).unwrap_or_default()
464                }
465                _ => Vec::new(),
466            };
467            let mut roster = lean_ctx_hive::workers::roster::render(
468                &agents,
469                instance.as_deref().map(|id| (id, book.as_slice())),
470                chrono::Utc::now(),
471            );
472            if let Some(store) = &store {
473                roster.push_str(&lean_ctx_hive::bus::presence::render_remote(
474                    store.data_dir(),
475                    chrono::Utc::now(),
476                ));
477            }
478            roster.push_str(&sessions);
479            roster
480        }
481
482        "post" => {
483            let Some(msg) = message else {
484                return "Error: message is required for post".to_string();
485            };
486            let cat = category.unwrap_or("status");
487            let from = current_agent_id.unwrap_or("anonymous");
488            let msg_privacy = privacy.map_or(PrivacyLevel::Team, PrivacyLevel::parse_str);
489            let msg_priority = priority.map_or(MessagePriority::Normal, MessagePriority::parse_str);
490            let ttl_hours = _ttl_hours.or(Some(scratchpad_default_ttl_hours()));
491            if msg_privacy == PrivacyLevel::Private && to_agent.is_none() {
492                return "Error: private messages require to_agent".to_string();
493            }
494            let request = AgentMessageRequest {
495                project_root: project_root.to_string(),
496                from_agent_id: from.to_string(),
497                to_agent_id: to_agent.map(str::to_string),
498                category: cat.to_string(),
499                message: msg.to_string(),
500                privacy: msg_privacy,
501                priority: msg_priority,
502                ttl_hours,
503                request_id: request_id.map(str::to_string),
504            };
505            match OclaRegistry::global().agent_gateway.route_message(&request) {
506                Ok(msg_id) => {
507                    let target = to_agent.unwrap_or("all agents (broadcast)");
508                    format!("Posted [{cat}] to {target}: {msg} (id: {msg_id})")
509                }
510                Err(e) => format!("Post failed: {e}"),
511            }
512        }
513
514        // The text of either outcome, for callers of this untyped entry
515        // point; the registered tool takes the typed one (`handle_read`).
516        "read" => match read_messages(current_agent_id, project_root) {
517            Ok(text) | Err(text) => text,
518        },
519
520        // This entry point has no `timeout_secs`: the default applies; the
521        // registered tool passes it (`handle_wait`).
522        "wait" => match handle_wait(current_agent_id, project_root, None) {
523            Ok(text) | Err(text) => text,
524        },
525
526        // Hive agent messages (I1). This entry point has no `type`/`reply_to`
527        // arguments: `category` carries the type; the registered tool passes all.
528        "send" => handle_message(
529            "send",
530            &MessageArgs::untyped(message, category, to_agent),
531            project_root,
532            current_agent_id,
533        ),
534        "reply" => handle_message(
535            "reply",
536            &MessageArgs::untyped(message, category, to_agent),
537            project_root,
538            current_agent_id,
539        ),
540        "inbox" => handle_message(
541            "inbox",
542            &MessageArgs::untyped(message, category, to_agent),
543            project_root,
544            current_agent_id,
545        ),
546
547        "status" => {
548            let Some(agent_id) = current_agent_id else {
549                return "Error: agent must be registered first".to_string();
550            };
551            let new_status = match status {
552                Some("active") => AgentStatus::Active,
553                Some("idle") => AgentStatus::Idle,
554                Some("finished") => AgentStatus::Finished,
555                Some(other) => {
556                    return format!("Unknown status: {other}. Use: active, idle, finished");
557                }
558                None => return "Error: status value is required".to_string(),
559            };
560            let status_msg = message;
561
562            match AgentRegistry::mutate_locked(|registry| {
563                registry.set_status(agent_id, new_status.clone(), status_msg)
564            })
565            .and_then(|(_, result)| result)
566            {
567                Ok(()) => format!(
568                    "Status updated: {} → {}{}",
569                    agent_id,
570                    new_status,
571                    status_msg.map(|m| format!(" ({m})")).unwrap_or_default()
572                ),
573                Err(e) => format!("Status update rejected: {e}"),
574            }
575        }
576
577        "info" => {
578            let registry = AgentRegistry::mutate_locked(|registry| {
579                registry.cleanup_stale(presence_ttl_hours());
580            })
581            .map(|(registry, ())| registry)
582            .unwrap_or_else(|_| AgentRegistry::load_or_create());
583            let total = registry.agents.len();
584            let active = registry.list_active(None).len();
585            let messages = registry.scratchpad.len();
586            format!(
587                "Agent Registry: {total} total, {active} active, {messages} scratchpad entries\nLast updated: {}",
588                registry.updated_at.format("%Y-%m-%d %H:%M UTC")
589            )
590        }
591
592        "handoff" => {
593            let Some(from) = current_agent_id else {
594                return "Error: agent must be registered first".to_string();
595            };
596            let Some(target) = to_agent else {
597                return "Error: to_agent is required for handoff".to_string();
598            };
599            let summary = message.unwrap_or("(no summary provided)");
600            let scratchpad_ttl = scratchpad_default_ttl_hours();
601            // A worker of another machine (`wkr_…`, as `ctx_agent list`
602            // shows it): the handoff is a typed message through Hive, which
603            // that worker answers (accept or decline). Any other target is
604            // an agent on this machine.
605            if target.starts_with("wkr_") {
606                use lean_ctx_hive::bus::messages_v2::{Extras, send};
607                let extras = Extras {
608                    to_worker: Some(target),
609                    ..Extras::default()
610                };
611                return match send(project_root, from, Some("handoff"), summary, extras) {
612                    Ok(id) => {
613                        let waiting = format!("handoff to {target} offered through Hive");
614                        let _ = AgentRegistry::mutate_locked(|registry| {
615                            registry.set_status(from, AgentStatus::Idle, Some(&waiting))
616                        });
617                        format!(
618                            "Handoff offered (hive): {from} → {target} message={id}; \
619                             {target} answers it (accept or decline)\nSummary: {summary}"
620                        )
621                    }
622                    Err(error) => format!("Handoff rejected: {error}"),
623                };
624            }
625
626            if let Err(error) = AgentRegistry::mutate_locked(|registry| {
627                // The handoff message must be persisted before the sender is
628                // marked Finished. A bound presence that finishes first can no
629                // longer post, so the delivery would be silently dropped.
630                registry.post_message_scoped(
631                    Some(project_root),
632                    from,
633                    Some(target),
634                    "handoff",
635                    &format!("HANDOFF from {from}: {summary}"),
636                    PrivacyLevel::Team,
637                    MessagePriority::Normal,
638                    Some(scratchpad_ttl),
639                )?;
640                registry.set_status(from, AgentStatus::Finished, Some("handed off"))
641            })
642            .and_then(|(_, result)| result)
643            {
644                return format!("Handoff rejected: {error}");
645            }
646
647            // Route through OCLA AgentGateway for cross-process visibility.
648            let handoff_message = format!("HANDOFF: {summary}");
649            let capsule_ref = global_capsule_store().register(handoff_message.as_bytes());
650            let mut envelope = AgentEnvelope {
651                schema_version: AGENT_ENVELOPE_SCHEMA_VERSION,
652                relay_id: String::new(),
653                context: OclaRequestContext::new(
654                    format!("handoff:{capsule_ref}"),
655                    project_root.to_string(),
656                    from.to_string(),
657                    capsule_ref.clone(),
658                    None,
659                    Some(format!("handoff:{from}:{target}")),
660                ),
661                from_agent_id: from.to_string(),
662                to_agent_id: target.to_string(),
663                capsule_ref,
664                budget_tokens: handoff_message.len().max(1) as u64,
665            };
666            if let Err(error) = envelope.assign_relay_id() {
667                tracing::debug!("OCLA relay setup failed (non-fatal): {error}");
668            } else if let Err(error) = OclaRegistry::global().agent_gateway.relay_agent(envelope) {
669                tracing::debug!("OCLA relay failed (non-fatal): {error}");
670            }
671
672            // Stigmergy (#540): mark the handed-off work as Done in the field
673            // so other agents see it arithmetically, without reading messages.
674            lean_ctx::core::scent_field::deposit_for(
675                project_root,
676                from,
677                lean_ctx::core::scent_field::ScentKind::Done,
678                summary,
679                1.0,
680            );
681
682            format!("Handoff complete: {from} → {target}\nSummary: {summary}")
683        }
684
685        // Stigmergic claim (#540): atomically claim a target (file, task,
686        // deploy unit) in the shared scent field. Fails fast when another
687        // agent's claim is still active — prevents duplicate work for ~0 tokens.
688        "claim" => {
689            let Some(target) = message else {
690                return "Error: message (the claim target, e.g. a file path or task label) is required for claim".to_string();
691            };
692            let agent = current_agent_id.map_or_else(
693                || lean_ctx::core::scent_field::scent_agent_id().to_string(),
694                str::to_string,
695            );
696            let normalized = lean_ctx::core::pathutil::normalize_tool_path(target);
697            match lean_ctx::core::scent_field::claim(project_root, &agent, &normalized) {
698                Ok(()) => {
699                    format!("Claimed: {normalized} (by {agent}, decays in ~10m unless re-claimed)")
700                }
701                Err(e) => format!("Claim REJECTED: {normalized} — {e}"),
702            }
703        }
704
705        // Release a stigmergic claim early (done or abandoned).
706        "release" => {
707            let Some(target) = message else {
708                return "Error: message (the claim target) is required for release".to_string();
709            };
710            let agent = current_agent_id.map_or_else(
711                || lean_ctx::core::scent_field::scent_agent_id().to_string(),
712                str::to_string,
713            );
714            let normalized = lean_ctx::core::pathutil::normalize_tool_path(target);
715            lean_ctx::core::scent_field::release(project_root, &agent, &normalized);
716            format!("Released: {normalized}")
717        }
718
719        // Sub-agent context contract (GL#450): deterministic briefing pack.
720        // `message` is the task; `priority` doubles as the token budget when
721        // numeric (default 2000). Same knowledge + task ⇒ byte-identical pack.
722        "brief" => {
723            let Some(task) = message else {
724                return "Error: message (the sub-agent task) is required for brief".to_string();
725            };
726            let budget = priority
727                .and_then(|p| p.parse::<usize>().ok())
728                .unwrap_or(2000);
729            let Some(knowledge) = lean_ctx::core::knowledge::ProjectKnowledge::load(project_root)
730            else {
731                return "No knowledge stored for this project yet — a briefing pack needs facts. Use ctx_knowledge(action=\"remember\") first.".to_string();
732            };
733            // The pack builder leaves out facts that may not be delivered now.
734            let pack =
735                lean_ctx::core::subagent_contract::build_briefing_pack(&knowledge, task, budget);
736            match lean_ctx::core::subagent_contract::serialize_pack(&pack) {
737                Ok(json) => json,
738                Err(e) => format!("Error: {e}"),
739            }
740        }
741
742        // Return synthesis (GL#450): distill a sub-agent's report into
743        // recallable parent knowledge. Expects contract-formatted lines
744        // ('category/key: value'); rejects are listed, never silently dropped.
745        "return" => {
746            let Some(report) = message else {
747                return "Error: message (the sub-agent report) is required for return".to_string();
748            };
749            let (facts, rejected) = lean_ctx::core::subagent_contract::parse_return_lines(report);
750            if facts.is_empty() {
751                return format!(
752                    "No contract-formatted lines found ({} rejected). Expected 'category/key: value' per line.",
753                    rejected.len()
754                );
755            }
756
757            let session_label = current_agent_id.unwrap_or("subagent");
758            let policy = match crate::tools::knowledge_shared::load_policy_or_error() {
759                Ok(p) => p,
760                Err(e) => return e,
761            };
762            let count = facts.len();
763            let res = lean_ctx::core::knowledge::ProjectKnowledge::mutate_locked(
764                project_root,
765                |knowledge| {
766                    for f in &facts {
767                        knowledge.remember(
768                            &f.category,
769                            &f.key,
770                            &f.value,
771                            session_label,
772                            0.8,
773                            &policy,
774                        );
775                    }
776                },
777            );
778            match res {
779                Ok(_) => {
780                    let mut out = format!(
781                        "Return synthesis: {count} fact(s) distilled into parent knowledge"
782                    );
783                    if !rejected.is_empty() {
784                        out.push_str(&format!(
785                            "\n{} line(s) rejected (not 'category/key: value'):",
786                            rejected.len()
787                        ));
788                        for r in rejected.iter().take(5) {
789                            out.push_str(&format!("\n  ✗ {r}"));
790                        }
791                    }
792                    out
793                }
794                Err(e) => format!("Error: knowledge store update failed: {e}"),
795            }
796        }
797
798        "sync" => {
799            let registry = AgentRegistry::mutate_locked(|registry| {
800                registry.cleanup_stale(presence_ttl_hours());
801            })
802            .map(|(registry, ())| registry)
803            .unwrap_or_else(|_| AgentRegistry::load_or_create());
804            let pending_count = current_agent_id.map_or(0, |id| {
805                registry
806                    .scratchpad
807                    .iter()
808                    .filter(|e| {
809                        !e.read_by.contains(&id.to_string())
810                            && e.from_agent != id
811                            && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(id))
812                            && e.project_root.as_deref().is_some_and(|own| {
813                                lean_ctx::core::pathutil::same_project(own, project_root)
814                            })
815                    })
816                    .count()
817            });
818            let agents = registry.list_active(Some(project_root));
819
820            if agents.is_empty() {
821                return "No active agents to sync with.".to_string();
822            }
823
824            // Only this project's shares (`ctx_share` partitions by project).
825            let shared_dir = lean_ctx::core::data_dir::lean_ctx_data_dir()
826                .unwrap_or_default()
827                .join("agents")
828                .join("shared")
829                .join(lean_ctx::core::project_hash::hash_project_root(
830                    project_root,
831                ));
832
833            let shared_count = if shared_dir.exists() {
834                std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
835            } else {
836                0
837            };
838
839            let mut out = "Multi-Agent Sync Status:\n".to_string();
840            out.push_str(&format!("  Active agents: {}\n", agents.len()));
841            for a in &agents {
842                let role = a.role.as_deref().unwrap_or("-");
843                let age = (chrono::Utc::now() - a.last_active).num_minutes();
844                out.push_str(&format!(
845                    "    {} [{}] role={} ({}m ago)\n",
846                    a.agent_id, a.agent_type, role, age
847                ));
848            }
849            out.push_str(&format!("  Pending messages: {pending_count}\n"));
850            out.push_str(&format!("  Shared contexts: {shared_count}\n"));
851
852            // Stigmergy (#540): arithmetic field view — claims, stuck markers,
853            // hot files across this project's agents, no scratchpad reads needed.
854            let scents = lean_ctx::core::scent_field::sync_block(project_root);
855            if !scents.is_empty() {
856                out.push_str(&scents);
857            }
858            out
859        }
860
861        // Premium control-plane snapshot: one bounded, read-only view of
862        // presence, messages and persisted Work Graph execution state.
863        // This keeps agents from guessing whether they should retry, wait or
864        // integrate, and gives the operator a stable next action.
865        "control_status" => control_status(project_root, current_agent_id),
866
867        "export" => {
868            let Some(agent_id) = current_agent_id else {
869                return "Error: agent must be registered first (use action=register)".to_string();
870            };
871
872            fn privacy_label(p: &PrivacyLevel) -> &'static str {
873                match p {
874                    PrivacyLevel::Public => "public",
875                    PrivacyLevel::Team => "team",
876                    PrivacyLevel::Private => "private",
877                }
878            }
879
880            fn priority_label(p: &MessagePriority) -> &'static str {
881                match p {
882                    MessagePriority::Low => "low",
883                    MessagePriority::Normal => "normal",
884                    MessagePriority::High => "high",
885                    MessagePriority::Critical => "critical",
886                }
887            }
888
889            fn maybe_redact(s: &str, should_redact: bool) -> String {
890                if should_redact {
891                    lean_ctx::core::redaction::redact_text(s)
892                } else {
893                    s.to_string()
894                }
895            }
896
897            #[derive(serde::Serialize)]
898            struct ExportAgentV1 {
899                agent_id: String,
900                agent_type: String,
901                role: Option<String>,
902                status: String,
903                status_message: Option<String>,
904                started_at: String,
905                last_active: String,
906                pid: u32,
907            }
908
909            #[derive(serde::Serialize)]
910            struct ExportMessageV1 {
911                id: String,
912                from_agent: String,
913                to_agent: Option<String>,
914                category: String,
915                privacy: String,
916                priority: String,
917                message: String,
918                metadata: std::collections::BTreeMap<String, String>,
919                timestamp: String,
920                expires_at: Option<String>,
921                read_by_count: usize,
922            }
923
924            #[derive(serde::Serialize)]
925            struct ExportTaskV1 {
926                id: String,
927                from_agent: String,
928                to_agent: String,
929                state: String,
930                description: String,
931                created_at: String,
932                updated_at: String,
933                messages: usize,
934                artifacts: usize,
935                transitions: usize,
936            }
937
938            #[derive(serde::Serialize)]
939            struct ExportDiaryEntryV1 {
940                entry_type: String,
941                content: String,
942                context: Option<String>,
943                timestamp: String,
944            }
945
946            #[derive(serde::Serialize)]
947            struct ExportDiaryV1 {
948                agent_id: String,
949                agent_type: String,
950                project_root: String,
951                updated_at: String,
952                entries: Vec<ExportDiaryEntryV1>,
953            }
954
955            #[derive(serde::Serialize)]
956            struct A2ASnapshotV1 {
957                schema_version: u32,
958                created_at: String,
959                project_root: String,
960                agent_id: String,
961                agents: Vec<ExportAgentV1>,
962                messages: Vec<ExportMessageV1>,
963                tasks: Vec<ExportTaskV1>,
964                diary: Option<ExportDiaryV1>,
965            }
966
967            let privacy_mode = privacy.unwrap_or("redacted");
968            let allow_full = privacy_mode == "full"
969                && !lean_ctx::core::redaction::redaction_enabled_for_active_role();
970            let should_redact = !allow_full;
971
972            let now = chrono::Utc::now();
973            let mut registry = AgentRegistry::load_or_create();
974            registry.cleanup_stale(presence_ttl_hours());
975
976            let mut agents: Vec<ExportAgentV1> = registry
977                .list_active(Some(project_root))
978                .into_iter()
979                .map(|a| ExportAgentV1 {
980                    agent_id: a.agent_id.clone(),
981                    agent_type: a.agent_type.clone(),
982                    role: a.role.clone(),
983                    status: a.status.to_string(),
984                    status_message: a.status_message.clone(),
985                    started_at: a.started_at.to_rfc3339(),
986                    last_active: a.last_active.to_rfc3339(),
987                    pid: a.pid,
988                })
989                .collect();
990            agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
991
992            let mut messages: Vec<ExportMessageV1> = registry
993                .scratchpad
994                .iter()
995                .filter(|e| {
996                    e.project_root.as_deref().is_some_and(|own| {
997                        lean_ctx::core::pathutil::same_project(own, project_root)
998                    }) && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
999                })
1000                .take(200)
1001                .map(|m| ExportMessageV1 {
1002                    id: m.id.clone(),
1003                    from_agent: m.from_agent.clone(),
1004                    to_agent: m.to_agent.clone(),
1005                    category: m.category.clone(),
1006                    privacy: privacy_label(&m.privacy).to_string(),
1007                    priority: priority_label(&m.priority).to_string(),
1008                    message: maybe_redact(&m.served_message(Some(project_root)), should_redact),
1009                    // The admission binding is store-internal provenance.
1010                    metadata: m
1011                        .metadata
1012                        .iter()
1013                        .filter(|(k, _)| k.as_str() != lean_ctx::core::agents::BINDING_METADATA_KEY)
1014                        .map(|(k, v)| (k.clone(), maybe_redact(v, should_redact)))
1015                        .collect(),
1016                    timestamp: m.timestamp.to_rfc3339(),
1017                    expires_at: m.expires_at.map(|t| t.to_rfc3339()),
1018                    read_by_count: m.read_by.len(),
1019                })
1020                .collect();
1021            messages.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then_with(|| a.id.cmp(&b.id)));
1022
1023            // Prune and read under the store lock: a concurrent task mutation
1024            // is either fully before or fully after this one, never lost.
1025            // Export runs once per request, so it waits for a busy store
1026            // instead of failing after the default lock timeout.
1027            const EXPORT_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
1028            let tasks = TaskStore::default_path().and_then(|path| {
1029                TaskStore::mutate_locked_within(&path, EXPORT_LOCK_WAIT, |task_store| {
1030                    task_store.cleanup_old(72);
1031                    Ok::<_, std::io::Error>(
1032                        task_store
1033                            .tasks_for_agent(agent_id)
1034                            .into_iter()
1035                            .take(200)
1036                            .map(|t| ExportTaskV1 {
1037                                id: t.id.clone(),
1038                                from_agent: t.from_agent.clone(),
1039                                to_agent: t.to_agent.clone(),
1040                                state: t.state.to_string(),
1041                                description: maybe_redact(&t.served_description(), should_redact),
1042                                created_at: t.created_at.to_rfc3339(),
1043                                updated_at: t.updated_at.to_rfc3339(),
1044                                messages: t.messages.len(),
1045                                artifacts: t.artifacts.len(),
1046                                transitions: t.history.len(),
1047                            })
1048                            .collect::<Vec<ExportTaskV1>>(),
1049                    )
1050                })
1051            });
1052            let mut tasks = match tasks {
1053                Ok(tasks) => tasks,
1054                Err(error) => {
1055                    tracing::warn!("lean-ctx: failed to read task store: {error}");
1056                    return "Error: task storage unavailable".to_string();
1057                }
1058            };
1059            tasks.sort_by(|a, b| {
1060                b.updated_at
1061                    .cmp(&a.updated_at)
1062                    .then_with(|| a.id.cmp(&b.id))
1063            });
1064
1065            let diary =
1066                AgentDiary::load_for_project(agent_id, project_root).map(|d| ExportDiaryV1 {
1067                    agent_id: d.agent_id,
1068                    agent_type: d.agent_type,
1069                    project_root: d.project_root,
1070                    updated_at: d.updated_at.to_rfc3339(),
1071                    entries: d
1072                        .entries
1073                        .iter()
1074                        .rev()
1075                        .take(25)
1076                        .rev()
1077                        .map(|e| ExportDiaryEntryV1 {
1078                            entry_type: e.entry_type.to_string(),
1079                            content: maybe_redact(&e.content, should_redact),
1080                            context: e.context.as_deref().map(|c| maybe_redact(c, should_redact)),
1081                            timestamp: e.timestamp.to_rfc3339(),
1082                        })
1083                        .collect(),
1084                });
1085
1086            let payload = A2ASnapshotV1 {
1087                schema_version: lean_ctx::core::contracts::A2A_SNAPSHOT_V1_SCHEMA_VERSION,
1088                created_at: now.to_rfc3339(),
1089                project_root: project_root.to_string(),
1090                agent_id: agent_id.to_string(),
1091                agents,
1092                messages,
1093                tasks,
1094                diary,
1095            };
1096
1097            let json = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string());
1098
1099            if write {
1100                let proofs_dir = std::path::Path::new(project_root)
1101                    .join(".lean-ctx")
1102                    .join("proofs");
1103                if let Err(e) = std::fs::create_dir_all(&proofs_dir) {
1104                    return format!("Error: create proofs dir: {e}");
1105                }
1106
1107                let name = if let Some(f) = filename {
1108                    let p = std::path::Path::new(f);
1109                    if p.components().count() != 1 {
1110                        return "Error: filename must be a plain file name (no directories)"
1111                            .to_string();
1112                    }
1113                    f.to_string()
1114                } else {
1115                    format!("a2a-snapshot-v1_{}.json", now.format("%Y%m%d_%H%M%S"))
1116                };
1117
1118                let out_path = proofs_dir.join(name);
1119                if let Err(e) = std::fs::write(&out_path, &json) {
1120                    return format!("Error: write snapshot: {e}");
1121                }
1122
1123                return format!(
1124                    "A2A snapshot exported: {}\n  agents: {}\n  messages: {}\n  tasks: {}",
1125                    out_path.display(),
1126                    payload.agents.len(),
1127                    payload.messages.len(),
1128                    payload.tasks.len()
1129                );
1130            }
1131
1132            match format.unwrap_or("json") {
1133                "text" => format!(
1134                    "A2A snapshot (v1)\n  agents: {}\n  messages: {}\n  tasks: {}",
1135                    payload.agents.len(),
1136                    payload.messages.len(),
1137                    payload.tasks.len()
1138                ),
1139                _ => json,
1140            }
1141        }
1142
1143        "diary" => {
1144            let Some(agent_id) = current_agent_id else {
1145                return "Error: agent must be registered first".to_string();
1146            };
1147            let Some(content) = message else {
1148                return "Error: message is required for diary entry".to_string();
1149            };
1150            let entry_type = match category.unwrap_or("progress") {
1151                "discovery" | "found" => DiaryEntryType::Discovery,
1152                "decision" | "decided" => DiaryEntryType::Decision,
1153                "blocker" | "blocked" => DiaryEntryType::Blocker,
1154                "progress" | "done" => DiaryEntryType::Progress,
1155                "insight" => DiaryEntryType::Insight,
1156                other => {
1157                    return format!(
1158                        "Unknown diary type: {other}. Use: discovery, decision, blocker, progress, insight"
1159                    );
1160                }
1161            };
1162            let atype = agent_type.unwrap_or("unknown");
1163            let mut diary = AgentDiary::load_or_create(agent_id, atype, project_root);
1164            let context_str = to_agent;
1165            diary.add_entry(entry_type.clone(), content, context_str);
1166            match diary.save() {
1167                Ok(()) => format!("Diary entry [{entry_type}] added: {content}"),
1168                Err(e) => format!("Diary entry added but save failed: {e}"),
1169            }
1170        }
1171
1172        "recall_diary" | "diary_recall" => {
1173            let Some(agent_id) = current_agent_id else {
1174                let diaries = AgentDiary::list_for_project(project_root);
1175                if diaries.is_empty() {
1176                    return "No agent diaries found.".to_string();
1177                }
1178                let mut out = format!("Agent Diaries ({}):\n", diaries.len());
1179                for (id, count, updated) in &diaries {
1180                    let age = (chrono::Utc::now() - *updated).num_minutes();
1181                    out.push_str(&format!("  {id}: {count} entries ({age}m ago)\n"));
1182                }
1183                return out;
1184            };
1185            match AgentDiary::load_for_project(agent_id, project_root) {
1186                Some(diary) => diary.format_summary(),
1187                None => format!("No diary found for agent '{agent_id}'."),
1188            }
1189        }
1190
1191        "diaries" => {
1192            let diaries = AgentDiary::list_for_project(project_root);
1193            if diaries.is_empty() {
1194                return "No agent diaries found.".to_string();
1195            }
1196            let mut out = format!("Agent Diaries ({}):\n", diaries.len());
1197            for (id, count, updated) in &diaries {
1198                let age = (chrono::Utc::now() - *updated).num_minutes();
1199                out.push_str(&format!("  {id}: {count} entries ({age}m ago)\n"));
1200            }
1201            out
1202        }
1203
1204        "share_knowledge" => {
1205            let cat = category.unwrap_or("general");
1206            let Some(msg_text) = message else {
1207                return "Error: message required (format: key1=value1;key2=value2)".to_string();
1208            };
1209            let facts: Vec<(String, String)> = msg_text
1210                .split(';')
1211                .filter_map(|kv| {
1212                    let (k, v) = kv.split_once('=')?;
1213                    Some((k.trim().to_string(), v.trim().to_string()))
1214                })
1215                .collect();
1216            if facts.is_empty() {
1217                return "Error: no valid key=value pairs found".to_string();
1218            }
1219            let Some(from) = current_agent_id else {
1220                return "Error: agent must be registered first".to_string();
1221            };
1222            // Shared knowledge reaches only the agents of this project.
1223            match AgentRegistry::mutate_locked(|registry| {
1224                registry.require_active_for_presence(from, "sharing knowledge")?;
1225                registry.share_knowledge(project_root, from, cat, &facts)
1226            })
1227            .and_then(|(_, shared)| shared)
1228            {
1229                Ok(count) => format!("Shared {count} facts in category '{cat}' with this project"),
1230                Err(e) => format!("Share failed: {e}"),
1231            }
1232        }
1233
1234        "receive_knowledge" => {
1235            let Some(agent_id) = current_agent_id else {
1236                return "Error: agent must be registered first".to_string();
1237            };
1238            let facts = match AgentRegistry::mutate_locked(|registry| {
1239                registry.require_active_for_presence(agent_id, "receiving knowledge")?;
1240                registry.receive_shared_knowledge(project_root, agent_id)
1241            })
1242            .and_then(|(_, facts)| facts)
1243            {
1244                Ok(facts) => facts,
1245                Err(e) => return format!("Receive failed: {e}"),
1246            };
1247            if facts.is_empty() {
1248                return "No new shared knowledge.".to_string();
1249            }
1250            let mut out = format!("Received {} facts:\n", facts.len());
1251            for f in &facts {
1252                let age = (chrono::Utc::now() - f.timestamp).num_minutes();
1253                out.push_str(&format!(
1254                    "  [{}] {} (from {}, {}m ago)\n",
1255                    f.category, f.text, f.from_agent, age
1256                ));
1257            }
1258            out
1259        }
1260
1261        "poll_events" => {
1262            let Some(agent_id) = current_agent_id else {
1263                return "Error: agent must be registered first".to_string();
1264            };
1265            let workspace_id = to_agent.unwrap_or(project_root);
1266            let channel_id = category.unwrap_or("default");
1267            let since_id: i64 = message.and_then(|s| s.parse().ok()).unwrap_or(0);
1268            let limit: usize = _ttl_hours.unwrap_or(50) as usize;
1269
1270            let rt = lean_ctx::core::context_os::runtime();
1271            let events = rt.bus.read(workspace_id, channel_id, since_id, limit);
1272
1273            let filter = lean_ctx::core::context_os::TopicFilter {
1274                agent_id: Some(agent_id.to_string()),
1275                kinds: privacy.and_then(|s| {
1276                    let kinds: Vec<_> = s
1277                        .split(',')
1278                        .map(|k| lean_ctx::core::context_os::ContextEventKindV1::parse(k.trim()))
1279                        .collect();
1280                    if kinds.is_empty() { None } else { Some(kinds) }
1281                }),
1282                ..Default::default()
1283            };
1284
1285            let filtered: Vec<_> = events.into_iter().filter(|e| filter.matches(e)).collect();
1286            if filtered.is_empty() {
1287                return format!("No new events since id={since_id} for {agent_id}.");
1288            }
1289
1290            let mut out = format!("Events ({}, since={since_id}):\n", filtered.len());
1291            for ev in &filtered {
1292                let actor = ev.actor.as_deref().unwrap_or("-");
1293                out.push_str(&format!(
1294                    "  #{} [{}] actor={} cl={} ({})\n",
1295                    ev.id,
1296                    ev.kind,
1297                    actor,
1298                    ev.consistency_level,
1299                    ev.timestamp.format("%H:%M:%S")
1300                ));
1301            }
1302            if let Some(last) = filtered.last() {
1303                out.push_str(&format!("cursor={}", last.id));
1304            }
1305            out
1306        }
1307
1308        // P11: path/symbol leases for mutation planning (see `lease`).
1309        "lease_acquire" => lease::acquire(project_root, current_agent_id, message, _ttl_hours),
1310        "lease_release" => lease::release(project_root, current_agent_id, message, category),
1311        "lease_renew" => lease::renew(
1312            project_root,
1313            current_agent_id,
1314            message,
1315            category,
1316            _ttl_hours,
1317        ),
1318
1319        _ => format!("Unknown action: {action}. Use: {}", ACTIONS.join(", ")),
1320    }
1321}
1322
1323#[cfg(test)]
1324#[path = "ctx_agent_tests.rs"]
1325mod tests;
1326// SPDX-License-Identifier: Apache-2.0