Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
265 lines
13 KiB
Markdown
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 11 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 11), 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.
|