Files
boxmaker/docs/plans/M5c2/10-memoryd-ask-memory.md
T
2026-09-28 21:27:10 -07:00

265 lines
13 KiB
Markdown

# M5c-2 task 10: `ask_memory` in `memoryd`, both chats at once
**Branch:** `m5c2`. Check it: `git branch --show-current` prints `m5c2` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `memoryd: ask_memory, each workspace's chat at once with its own limit`
**Log row:** the Task cell is `M5c2/10-memoryd-ask-memory`.
## Goal
`ask_memory { question, workspaces }` (task 01) asks Honcho's dialectic about one or two
workspaces (M5c spec, section 9). `brokerd` sends it to `memoryd` (tasks 02, 03), and `loopd`
declares it (task 04). This task writes what `memoryd` does with it, as a new module
`crates/memoryd/src/honcho/ask.rs`, modelled on task 08's `search.rs`. Task 12 wires it into
`memoryd`'s serving; until then nothing calls it.
- **`ask(readers, args, wait, log) -> Asked`**:
1. **The names.** `args.asked()` gives the names, `own` when none is given. Each is resolved
with `Readers::resolve` (task 05). If a name is unknown, nothing is sent, not even for the
other name: the answer is `resolve`'s sentence for it (`no workspace is named boxmaker9;
ask own, untrusted, fleet, boxmaker1 or boxmaker2`).
2. **A secret.** If `decide::has_secret(&args.question)`, nothing is sent: the answer is
`ask_memory: not asked; the question looks like it holds a secret`.
3. **Both chats at once**, one thread each: `workspace_chat` at reasoning level `minimal`,
always (spec 9: a `low` chat recorded from rift answered with raw tool-call markup), with
the question and nothing else. `roncho`'s blocking `workspace_chat` refuses a `target` or
`filters`; neither is set.
4. **Each chat has `wait`** (150 s in `memoryd`, `CHAT_WAIT`, inside `brokerd`'s 170 s),
counted from one deadline taken when the chats start. A chat that failed, or has not
answered by then, is unavailable. A workspace that does not exist answers a chat 404
(recorded, fixture 36), so it is unavailable too. A late thread holds no lock; its answer is
dropped. Each unavailable workspace is one log line, `memoryd: ask_memory: {name}
unavailable: {error}` (the error's kind and status, or `no answer in {wait} ms`), never the
question or an answer.
- **`answer(asked) -> Recalled`**:
- each workspace that answered, in the order named, as an item in task 06's form: a header
`[n] {tag} · {name} · {class}`, whose "from" is just the name as `ask_memory` knows it
(`own`, `untrusted`, `fleet`, or the agent's name), and whose tag and class are the
workspace's label (task 05); then the answer on one indented line;
- the text is `neutralise`d, and every control character and line or paragraph separator is a
space, as task 07's `snippet`, but cut to **2,000 characters** (`ANSWER_CAP`), not 600: a
chat answers with a paragraph or two of reasoning, not a snippet. An answer that is empty
once trimmed is `(no answer)`;
- then one `{name}: unavailable` line for each workspace that did not answer;
- the label: the highest class of the answers shown, `private` at the least, and untrusted
when any shown answer is (`memory-tainted`).
- **`recall::one_line(text, cap)`**: task 07's rule with a cap of the caller's. `snippet` becomes
`one_line(text, SNIPPET)`, so its callers do not change.
- **The fake Honcho** gets a new version: a workspace chat answers fixture 19 (the chat recorded
from rift at `minimal`), and `Mode::Chat(text)` answers a chat with the given text.
## Every place that changes
This is the complete list.
| Place | What happens there |
|---|---|
| `crates/memoryd/src/honcho/ask.rs` | new: a skeleton you copy, then fill 2 bodies |
| `crates/memoryd/src/honcho/mod.rs` | `pub mod ask;` (edit H1) |
| `crates/memoryd/src/recall.rs` | `one_line`, and `snippet` calls it (edit R1) |
| `crates/memoryd/tests/honcho_ask.rs` | new, given |
| `crates/memoryd/tests/support/honcho.rs` | new version, given |
| everything else | **no change**: not `serve.rs` (task 12), not `search.rs`, no `Cargo.toml` |
## Files
- Copy, from `docs/plans/M5c2/files/` (the same path under it): the new test
`crates/memoryd/tests/honcho_ask.rs`; a **new version** of
`crates/memoryd/tests/support/honcho.rs` (it replaces the file in the tree; the given
`honcho.rs-task08` beside it is task 08's, not yours); and the skeleton
`crates/memoryd/src/honcho/ask.rs`.
- Modify: `crates/memoryd/src/honcho/mod.rs`, `crates/memoryd/src/recall.rs`,
`docs/implementer-log.md`.
- Do not touch: any other file, and in the skeleton anything but its two `todo!()` bodies.
## Facts you need
- **The skeleton** is written except two bodies: `chat` (one workspace's chat) and `answer`.
`ask` (the names, the secret, the threads, the limit, the log) and the types are written; read
them before you fill the bodies. `ask` calls `chat` on each thread.
- **`roncho`'s calls and types** (this table is complete; `roncho`'s source, in the Cargo
registry, is outside the repository and off limits):
| Item | What it does |
|---|---|
| `Client::workspace_chat(&self, &DialecticOptions) -> Result<ChatResponse, roncho::blocking::Error>` | `POST /v3/workspaces/{w}/chat`, not streamed; refuses `target` or `filters` |
| `DialecticOptions { session_id, filters, scope, target, query: String, stream, reasoning_level: Option<ReasoningLevel>, response_format, include_evidence }` | `#[derive(Default)]`: `DialecticOptions { query, reasoning_level: Some(..), ..Default::default() }` |
| `ReasoningLevel::Minimal` | written `"minimal"` in the request |
| `ChatResponse::content(&self) -> &str` | the answer's text, `""` when Honcho sent none |
The request `chat` sends is exactly `{"query": question, "reasoning_level": "minimal"}`; the
given test checks it.
- **From earlier tasks**: `classify` (M5c-1); `Reader::chat() -> &Client`, `Label::tag()`,
`Label::untrusted()`, `Label { name, class, .. }` (task 05); `Item`, `render`, `label`,
`unavailable` (task 06); `Recalled` (M5a) and the new `one_line` (edit R1).
- **The given test** `honcho_ask.rs` (10 tests; about 3 s), against the fake Honcho:
- the limits and the sentences;
- the default: one chat, to `own`'s workspace, with exactly `{"query", "reasoning_level":
"minimal"}`; `[1] memory · own · private`, trusted;
- `untrusted` and `fleet` `memory-tainted`, making the answer untrusted; a trusted read agent
`memory` with its class; an untrusted one `memory-tainted` (T58);
- both workspaces asked at once: both hanging, answered after one limit, not two;
- one chat hanging: `unavailable` after the limit, the other answered;
- a missing workspace (404) and a 503: `unavailable`, logged by kind and status, never the
question;
- an unknown name: nothing sent, `resolve`'s sentence;
- a question holding a secret: nothing sent, the secret sentence;
- a forged header in an answer kept inside its item (T37);
- the 2,000-character cap counted in characters, neutralising, and `(no answer)`.
A key and a chat-template tag in it are built from pieces; no assertion prints a tag.
- The calls the bodies use: `DialecticOptions`, `ReasoningLevel::Minimal`,
`Client::workspace_chat`, `Result::map_err`, `classify`, `ChatResponse::content`; `one_line`,
`str::trim`, `Item`, `render`, `unavailable`, `label`, `slice::join`.
## The edits, exactly
Nothing else in these files changes. Each quoted "before" block occurs exactly once in its file.
### `crates/memoryd/src/honcho/mod.rs`
**H1.** Add one line. The line
```rust
pub mod client;
```
becomes
```rust
pub mod ask;
pub mod client;
```
### `crates/memoryd/src/recall.rs`
**R1.** The lines
```rust
/// A hit's text as an item holds it, from the local index or from Honcho: neutralised, every
/// control character (a line break among them) and every Unicode line or paragraph separator a
/// space, then cut to its first `SNIPPET` characters, with no mark.
pub fn snippet(text: &str) -> String {
neutralise(text)
.chars()
.map(|c| {
if c.is_control() || c == '\u{2028}' || c == '\u{2029}' {
' '
} else {
c
}
})
.take(SNIPPET)
.collect()
}
```
become
```rust
/// A hit's text as an item holds it, from the local index or from Honcho: `one_line`, cut to
/// `SNIPPET` characters.
pub fn snippet(text: &str) -> String {
one_line(text, SNIPPET)
}
/// Text from memory as an item holds it: neutralised, every control character (a line break
/// among them) and every Unicode line or paragraph separator a space, then cut to its first
/// `cap` characters, with no mark.
pub fn one_line(text: &str, cap: usize) -> String {
neutralise(text)
.chars()
.map(|c| {
if c.is_control() || c == '\u{2028}' || c == '\u{2029}' {
' '
} else {
c
}
})
.take(cap)
.collect()
}
```
## Steps
- [ ] **1. Copy the given tests.** From the repository root, two commands:
```sh
cp docs/plans/M5c2/files/crates/memoryd/tests/honcho_ask.rs crates/memoryd/tests/
cp docs/plans/M5c2/files/crates/memoryd/tests/support/honcho.rs crates/memoryd/tests/support/
```
Then `git status --short` lists `crates/memoryd/tests/support/honcho.rs` as modified and
`honcho_ask.rs` as new.
- [ ] **2. See them fail.** `cargo build --tests --keep-going -p memoryd`, with a shell timeout of
600000 ms: one test target, `honcho_ask`, does not compile: `E0432`, ``unresolved import
`memoryd::honcho::ask` ``. Every other target compiles.
- [ ] **3. The skeleton and the edits.** Copy the skeleton, then make edits H1 and R1:
```sh
cp docs/plans/M5c2/files/crates/memoryd/src/honcho/ask.rs crates/memoryd/src/honcho/
```
`cargo check -p memoryd`: no error, and 7 warnings, all from the unfilled bodies: unused
imports (`DialecticOptions` and `ReasoningLevel`; `classify`; `Item`, `label`, `render` and
`unavailable`; `one_line`) and unused variables (`reader`, `question`, `asked`).
- [ ] **4. Fill the bodies**, one at a time, from their comments, with `cargo check -p memoryd`
after each: `chat`, then `answer`. Delete each instruction comment and keep the doc comment
above the function. After the last, `cargo check -p memoryd` prints no warning.
- [ ] **5. The tests.** With a shell timeout of 600000 ms, every test passes: 10 in
`honcho_ask`, and as before 4 in `recall_form`, 6 in `recall`, 11 in `honcho_recalled` and 12
in `honcho_search`:
```sh
cargo test -p memoryd --test honcho_ask --test recall_form --test recall \
--test honcho_recalled --test honcho_search
```
- [ ] **6. Check.** `git diff --stat` lists exactly `crates/memoryd/src/honcho/mod.rs`,
`crates/memoryd/src/recall.rs` and `crates/memoryd/tests/support/honcho.rs`, and
`git status --short` shows `ask.rs` and `honcho_ask.rs` as new.
- [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms.
Expected last line: `gate: ok`. The test count is the passed total of `cargo test --workspace`
plus one, for the ignored `end_to_end` test that the gate runs on its own: **1444** after this
task, 1434 before it; the 10 new ones are the tests of `honcho_ask.rs`. To count, add up the
`N passed` of every `test result:` line the gate's `cargo test --workspace` printed, and add
1. Never run the gate again only to count.
- [ ] **8. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is
`M5c2/10-memoryd-ask-memory`. Stage with
`git add crates/memoryd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says.
`Cargo.lock` does not change in this task; the `git add` is harmless.
## Done when
- `make gate` prints `gate: ok` with 1444 tests.
- `git diff HEAD~1 --stat` lists only `mod.rs`, `recall.rs`, `ask.rs`, `honcho_ask.rs`,
`support/honcho.rs` and `docs/implementer-log.md`.
## Stop and report if
- The branch check at the top fails.
- A "before" block is not in its file exactly as shown, or is there more than once.
- Step 2 or 3 shows a different result: other targets failing, other errors, or warnings about
anything but the unfilled bodies.
- `cargo check` reports an error after a body, and its comment does not tell you how to fix it.
Quote the error, and stop.
- After step 4 a test fails although each body follows its comment. Do not change a test, the
fake or the written parts of the skeleton: stop, and give the failing tests' names and their
messages.
- The fix seems to need a change anywhere else: `serve.rs`, `search.rs`, `read.rs`,
`recalled.rs`, a new dependency, a streamed chat. It does not; stop and say why you think so.
- A test outside the given files fails in the gate: check once whether it fails without your
change (`git stash -u`, run it, `git stash pop`), then stop and report both results.
- The gate passes with a count other than 1444.
- A comment in the skeleton does not fit its signature. Quote it and stop.
## What the given tests pin
Each of these changes to the finished code makes at least one given test fail (18 tried, all
caught): reasoning at `low`, or none; an unknown name skipped instead of refused; the secret
check skipped; the default not `own`; no limit on the wait; the chats asked one after another;
a failed chat not reported unavailable; the cap 600, or the raw text uncapped and unflattened;
an empty answer shown as empty; the tag, the trust or the class not the workspace's; the
`unavailable` lines left out; a line break kept by `one_line`; `snippet` cut at 2,000; the limit
180 s.