polyc-tools 2026.8.3

The in-process tool core for polychrome agents: local executors (coding, web fetch, wallet, ...), the tool registry, and MCP composition. The networked connectors live in polyc-connectors.
//! Specs for the deliberate persona-memory tools: the `memory_write` note
//! (`#1139`) and the `memory_recall` read
//! (`docs/reference/agent-read-surface.md`).
//!
//! Like the own-conversation read family, neither has an in-process
//! implementation: the conversation sandbox cannot reach the persona-memory
//! journal, so the harness advertises both via the control-plane proxy and the
//! control plane runs each over the CALLER'S OWN memory partition
//! (`crate::conversation` documents the shared transport).
//!
//! Neither carries the `conversation_*` prefix, deliberately: persona memory
//! spans conversations, so sharing that family's namespace would misstate what
//! these reach.
//!
//! # Why the write is gated and the read is not
//!
//! [`MEMORY_WRITE`] is a write, and a sensitive one: a durable note persists
//! across conversations. The spec therefore carries the intrinsic approval gate
//! (`needs_approval`) and never a cacheable approval — every write is its own
//! decision, because the risk lives in the argument (the note text), not the
//! tool name (INV-C9: the write is a gated, annotated call, never a silent side
//! effect of generation). The control plane re-enforces the write rules
//! trusted-side: the same hard scope predicate and the same PII refusal as
//! post-turn extraction, landing as the same event kinds.
//!
//! [`MEMORY_RECALL`] persists nothing. Its control surface is the GRANT: both
//! names sit in [`ALL`], which `crate::capability::management::ALL` chains, so
//! neither ever defaults on for an agent with an unset `builtinTools` — an
//! un-granted `memory_recall` is not advertised and not executable. A per-call
//! approval on top of that would reproduce exactly the friction that left the
//! deleted SQL hatch unused. The accepted cost is stated plainly: once granted,
//! recalled memory content reaches the model with no per-call human check, so
//! the write-side PII refusal is the only filter and it is heuristic.

use polyc_llm::ToolSpec;
use serde_json::json;

/// The `memory_write` tool name.
pub const MEMORY_WRITE: &str = "memory_write";
/// The `memory_recall` tool name.
pub const MEMORY_RECALL: &str = "memory_recall";

/// Every deliberate-memory tool name, for allowlist checks and dispatch.
pub const ALL: &[&str] = &[MEMORY_WRITE, MEMORY_RECALL];

/// Every deliberate-memory tool spec.
#[must_use]
pub fn all_specs() -> Vec<ToolSpec> {
    vec![write_spec(), recall_spec()]
}

/// `memory_write` spec — record one durable note about the person the caller
/// is talking to, run trusted-side over the caller's own memory.
///
/// Annotations are the load-bearing half (CONF-14, the context-engineering suite of epic #1059): `approval_required` so a
/// write always routes through the human approval gate, and deliberately NOT
/// `cacheable_approval` — approving one note must never blanket-approve every
/// future note this session.
#[must_use]
pub fn write_spec() -> ToolSpec {
    ToolSpec::new(
        MEMORY_WRITE,
        "Save one durable note about the person you're talking to, so you \
         remember it in later conversations — a preference, a project, a \
         standing constraint. Use it when they share something worth keeping \
         or ask you to remember something. Keep the note to one \
         self-contained sentence. Never save addresses, phone or account \
         numbers, government ids, passwords or keys, or health details — \
         notes carrying those are refused. Pick who the note is for: \
         \"private\" is remembered only in one-on-one conversations with this \
         person (and can only be saved from one), \"portable\" travels with \
         them everywhere you talk, where other people may hear it too.",
        json!({
            "type": "object",
            "properties": {
                "note": {
                    "type": "string",
                    "description": "The note to keep — one self-contained sentence."
                },
                "audience": {
                    "type": "string",
                    "enum": ["private", "portable"],
                    "description": "\"private\" recalls only in one-on-one \
                        conversations with this person; \"portable\" recalls \
                        anywhere they talk to you, including rooms other \
                        people read."
                },
                "entities": {
                    "type": "array",
                    "items": { "type": "string" },
                    "description": "Names or topics the note mentions, to help recall it later."
                }
            },
            "required": ["note", "audience"],
            "additionalProperties": false
        }),
    )
    .titled("Save a note to memory")
    .approval_required()
}

/// `memory_recall` spec — read back the notes already kept about the person the
/// caller is talking to, run trusted-side over the caller's own memory.
///
/// Recall is scope plus recency and nothing else: a thin wrapper over
/// `polyc_persona_memory::select_for_context`, which takes a scope predicate
/// and a recency ranking and has NO query parameter. There is deliberately no
/// `query` argument here — adding text filtering would be new machinery in
/// `polyc-persona-memory`, and it should be built on evidence that agents fail
/// without it rather than on the symmetry of a schema.
///
/// The same hard scope predicate the per-turn injection applies runs here: a
/// note kept in a one-on-one conversation never surfaces on a multi-party
/// surface, and a portable note follows the persona everywhere; a private one never leaves a one-to-one surface.
#[must_use]
pub fn recall_spec() -> ToolSpec {
    ToolSpec::new(
        MEMORY_RECALL,
        "Read back the notes you have kept about the person you're talking to \
         — preferences, projects, standing constraints — most recent first, \
         along with short summaries of your earlier conversations with them. \
         Use it when you need to check what you already know before asking \
         them again, or when they refer to something settled in a past \
         conversation. Notes come back by recency, not by topic: there is \
         nothing to search on, so ask for a few more and read them. `limit` is \
         trimmed to 25, and leaving it out returns 12. Having kept no notes \
         yet is an ordinary empty result, not an error. This reads your notes \
         about this person and nobody else's, and it saves nothing — to keep \
         something new, use memory_write. For what was said inside THIS \
         conversation, use conversation_find instead.",
        json!({
            "type": "object",
            "properties": {
                "limit": {
                    "type": "integer",
                    "description": "Most notes to return, most recent first (default 12, at most 25).",
                    "minimum": 1
                },
                "detail": {
                    "type": "string",
                    "enum": ["concise", "full"],
                    "description": "\"concise\" (the default) returns just the note text and when it was learned. \"full\" adds the topics each note mentions, how confident it is, and whether it is a standing fact or one tied to a finished activity."
                }
            },
            "additionalProperties": false
        }),
    )
    .titled("Recall what you know about this person")
    .read_only()
    .cacheable_approval()
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use super::*;

    /// TEST-14 (first half, CONF-14): the spec carries the sensitive/gated
    /// annotation — a deliberate memory write is never silently auto-approved
    /// by default, and an approval is never rememberable across calls.
    #[test]
    fn write_spec_is_gated_and_never_cacheable() {
        let spec = write_spec();
        assert!(
            spec.needs_approval,
            "memory_write must carry the intrinsic approval gate (INV-C9)"
        );
        assert!(
            !spec.cacheable_approval,
            "approving one note must never blanket-approve every future note"
        );
        assert!(!spec.read_only, "a durable write is not a read");
    }

    #[test]
    fn all_specs_carries_exactly_the_two_memory_tools() {
        let names: Vec<String> = all_specs().into_iter().map(|s| s.name).collect();
        assert_eq!(
            names,
            vec![MEMORY_WRITE.to_owned(), MEMORY_RECALL.to_owned()]
        );
        assert_eq!(ALL, &[MEMORY_WRITE, MEMORY_RECALL]);
    }

    /// The read is ungated and cacheable, and the write is neither — the
    /// asymmetry the module doc argues for, pinned so a later copy edit cannot
    /// quietly make recall pause or make the write cacheable.
    #[test]
    fn recall_is_an_ungated_cacheable_read() {
        let spec = recall_spec();
        assert!(spec.read_only, "recall persists nothing");
        assert!(!spec.destructive);
        assert!(
            !spec.needs_approval,
            "the grant is the control surface for recall, not a per-call pause"
        );
        assert!(spec.cacheable_approval);
        assert!(!spec.open_world, "a persona's own notes are first-party");
        assert!(spec.title.is_some());
    }

    /// No `query` argument, and the schema is closed — so a model that tries to
    /// pass one is corrected by the schema rather than having it silently
    /// ignored. Recall is scope plus recency; `select_for_context` has nothing
    /// to filter on.
    #[test]
    fn recall_takes_no_query_argument() {
        let schema = recall_spec().schema_json;
        assert_eq!(schema["additionalProperties"], json!(false));
        let properties = schema["properties"]
            .as_object()
            .expect("recall's schema has properties");
        let mut keys: Vec<&String> = properties.keys().collect();
        keys.sort();
        assert_eq!(
            keys,
            vec!["detail", "limit"],
            "recall accepts a bound and a width, and nothing that filters"
        );
        assert!(schema.get("required").is_none(), "every argument optional");
    }
}