update: memoryシステム周りのプロンプトの整理
This commit is contained in:
@@ -5,20 +5,20 @@
|
||||
//! の観点で整理する disposable Worker を、Pod 側が組み立てるための
|
||||
//! ヘルパー群を提供する。Pod は次の手順で sub-Worker を構築する:
|
||||
//!
|
||||
//! - [`CONSOLIDATION_SYSTEM_PROMPT`] を sub-Worker の system prompt に
|
||||
//! - [`build_consolidate_input`] を sub-Worker の最初の user 入力に
|
||||
//! - memory 専用 Tool (read / write / edit) と Knowledge / memory 検索ツールを登録
|
||||
//! - [`StagingLock::acquire`] で並走防止 + consumed ID 確定
|
||||
//! - sub-Worker run 完了後、[`StagingLock::release_with_cleanup`] で
|
||||
//! consumed ID 分の staging のみ削除し、占有ファイルを解放
|
||||
//!
|
||||
//! Knowledge 化候補レポートと使用頻度メトリクスは別チケットで供給される
|
||||
//! 想定。本モジュール時点では空入力として扱い、prompt 側の説明だけ
|
||||
//! 残しておく(`docs/plan/memory.md` §Phase 2 / 整理材料)。
|
||||
//! system prompt は Pod の `PromptCatalog`
|
||||
//! (`PodPrompt::MemoryConsolidationSystem`) で管理される。Knowledge 化候補
|
||||
//! レポートと使用頻度メトリクスは別チケットで供給される想定。本モジュール
|
||||
//! 時点では空入力として扱い、prompt 側の説明だけ残しておく
|
||||
//! (`docs/plan/memory.md` §Phase 2 / 整理材料)。
|
||||
|
||||
mod input;
|
||||
mod lock;
|
||||
mod prompt;
|
||||
mod staging;
|
||||
mod tidy;
|
||||
|
||||
@@ -27,6 +27,5 @@ pub use input::{
|
||||
render_staging_records, render_tidy_hints,
|
||||
};
|
||||
pub use lock::{LockError, LockRecord, StagingLock};
|
||||
pub use prompt::CONSOLIDATION_SYSTEM_PROMPT;
|
||||
pub use staging::{StagingEntry, list_staging_entries};
|
||||
pub use tidy::{TidyHints, collect_tidy_hints};
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
//! Phase 2 sub-Worker の system prompt。
|
||||
//!
|
||||
//! 内容は `docs/plan/memory-prompts.md` §共通原則 / §Phase 2 統合 + 整理 /
|
||||
//! §Phase 2 Knowledge 書き込み を縮約。統合 phase / 整理 phase は同じ
|
||||
//! prompt 1 本で順に進める縛り(agent から見ると 1 セッション内のフェーズ
|
||||
//! 進行、別 trigger / 別 Worker は持たない、`docs/plan/memory.md` §整理
|
||||
//! の扱い)。
|
||||
|
||||
pub const CONSOLIDATION_SYSTEM_PROMPT: &str = r#"You are the Phase 2 consolidation worker for an INSOMNIA memory subsystem.
|
||||
|
||||
Your job is to take Phase 1 activity-log staging entries together with the workspace's current `memory/*` / `knowledge/*` records, then run two phases back-to-back in this single session:
|
||||
|
||||
1. **Consolidation phase** — fold staging into memory and knowledge.
|
||||
2. **Tidy phase** — clean up the existing records that the consolidation phase didn't already touch.
|
||||
|
||||
You have:
|
||||
- `MemoryRead`, `MemoryWrite`, `MemoryEdit` for memory and knowledge records.
|
||||
- `MemoryQuery` for memory-side records (summary / decisions / requests).
|
||||
- `KnowledgeQuery` for knowledge records — use it to find existing slugs before creating new ones.
|
||||
|
||||
Your initial user message contains the staging entries, the full memory records, the knowledge candidate report, and the tidy hints. Existing knowledge bodies are NOT in the prompt; pull them through `KnowledgeQuery` + `MemoryRead` when relevant.
|
||||
|
||||
# Common rules (both phases)
|
||||
|
||||
- **Do not invent provenance.** Decisions / Requests `sources` arrays MUST be copied from the staging `source` field for the originating activity log entries. Do not synthesise `session_id` or entry ranges. Do not fabricate `last_sources` for Knowledge.
|
||||
- **Rewrite is allowed and often preferred over append.** When integrating new information, restructure existing records to raise information density. Preserve the existing claims, rationale, and `sources` while you compress.
|
||||
- **Update over create.** If an existing slug fits, edit it. Only create a new slug when no existing record fits and you can articulate why.
|
||||
- **`replaced` over delete.** When a Decision is superseded by a different one, mark the old one `status: replaced` with `replaced_by: <new-slug>`. Do not silently drop it.
|
||||
- **Don't duplicate static docs.** Skip content that already lives in `AGENTS.md`, `docs/plan/*`, or other fixed project documents.
|
||||
- **Empty output is fine.** If a staging entry doesn't justify a memory write, skip it.
|
||||
- **Slug rules.** Slugs are kebab-case, short, recognisable, and must be unique within their kind. Same-slug create is a linter error — use Edit instead.
|
||||
- **Linter errors come back as tool errors.** When the memory linter rejects a write, read the error, fix the issue (missing frontmatter field, oversized body, unknown reference, etc.), and try again. Do not work around the rule.
|
||||
|
||||
# Consolidation phase
|
||||
|
||||
Walk every staging entry in the input. For each one:
|
||||
|
||||
- Add or update `decisions` / `requests` records as appropriate. Copy `sources` verbatim from the staging entry.
|
||||
- Update existing knowledge records when the staging activity refines them. Use `KnowledgeQuery` to find candidates before creating anything new.
|
||||
- **Knowledge creation is gated.** Only create a new `knowledge/<slug>.md` when the originating source appears in the supplied "Knowledge candidate report". When the report is empty (the metrics pipeline is still being built), do not create new knowledge — fold the activity into decisions / requests / summary or update existing knowledge instead.
|
||||
- Rewrite `memory/summary.md` only when needed. Aim for 1–5k tokens. Preserve the high-level shape (current focus, recent decisions, stable facts) while pruning stale items.
|
||||
|
||||
# Tidy phase
|
||||
|
||||
Once the consolidation phase is done, evaluate every existing memory and knowledge record against four categories:
|
||||
|
||||
- `outdated`: was correct, no longer matches the current implementation / policy / operation.
|
||||
- `superseded`: another record is now the de-facto authoritative one; this one is mostly redundant.
|
||||
- `unused`: not wrong, but rarely referenced — noise rather than signal.
|
||||
- `noisy`: useful content but bad shape (overlap, sources accumulation, fractured slugs that should merge).
|
||||
|
||||
A single record may fall into more than one category. Choose one of `drop / merge / split / trim / rewrite`:
|
||||
|
||||
- Prefer `merge` and `trim` over `drop` for anything you'd flag as `unused` or `noisy` — git can reverse you, but a confidently-wrong drop hurts discovery.
|
||||
- `drop` is allowed for `outdated` / `superseded` records you can justify in the diff.
|
||||
- `replaced` markers (`status: replaced`) and chains pointed at by the tidy hints should be collapsed in this phase.
|
||||
|
||||
**Protection threshold.** When the tidy hints include explicit-invoke metrics, records with `frequency >= 1.0 invokes/Mtoken` are off-limits to drop / large compression. The metrics pipeline is not always populated; when the input lacks frequency data, behave conservatively and skip drop on long-standing records.
|
||||
|
||||
# Closing the turn
|
||||
|
||||
When both phases are done, write a short final assistant message stating:
|
||||
|
||||
- which staging entries you folded in (by short summary, not by ID),
|
||||
- which existing records you touched (slug + operation),
|
||||
- anything you intentionally left alone and why.
|
||||
|
||||
Then end the turn. Do not ask questions — there is no human in the loop for this run.
|
||||
"#;
|
||||
@@ -4,13 +4,14 @@
|
||||
//! 出力を `<workspace>/.insomnia/memory/_staging/<id>.json` に書き出す
|
||||
//! ヘルパーを提供する。Pod 側はこのモジュールから:
|
||||
//!
|
||||
//! - [`EXTRACT_SYSTEM_PROMPT`] を sub-Worker の system prompt に
|
||||
//! - [`build_extract_input`] を sub-Worker の最初の user 入力に
|
||||
//! - [`write_extracted_tool`] を唯一のツールとして
|
||||
//! - [`write_staging`] で受け取った JSON を staging に書き出し
|
||||
//!
|
||||
//! の順で組み立てる。pointer 永続化(session-store の
|
||||
//! `LogEntry::Extension`、domain `"memory.extract"`)は Pod 側が責務を持つ。
|
||||
//! の順で組み立てる。system prompt は Pod の `PromptCatalog`
|
||||
//! (`PodPrompt::MemoryExtractSystem`) で管理される。pointer 永続化
|
||||
//! (session-store の `LogEntry::Extension`、domain `"memory.extract"`)は
|
||||
//! Pod 側が責務を持つ。
|
||||
//!
|
||||
//! 出力 JSON の wrap は [`write_staging`] が `source: { session_id, range }`
|
||||
//! を機械付与する形で担当し、LLM には source を推論させない。
|
||||
@@ -18,7 +19,6 @@
|
||||
mod input;
|
||||
mod payload;
|
||||
mod pointer;
|
||||
mod prompt;
|
||||
mod staging;
|
||||
mod tool;
|
||||
|
||||
@@ -27,7 +27,6 @@ pub use payload::{
|
||||
AttemptEntry, DecisionEntry, DiscussionEntry, ExtractedPayload, RequestEntry, StagingRecord,
|
||||
};
|
||||
pub use pointer::{ExtractPointerPayload, fold_pointer};
|
||||
pub use prompt::EXTRACT_SYSTEM_PROMPT;
|
||||
pub use staging::{StagingError, write_staging};
|
||||
pub use tool::{ExtractWorkerContext, write_extracted_tool};
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
//! Phase 1 sub-Worker の system prompt。
|
||||
//!
|
||||
//! 内容は `docs/plan/memory-prompts.md` §共通原則 / §Phase 1 を縮約。
|
||||
//! 「派生物を作らず、起きたことを抽出する」段階に縛り、JSON schema
|
||||
//! 準拠以外の自由文を許さない。
|
||||
|
||||
pub const EXTRACT_SYSTEM_PROMPT: &str = r#"You are the Phase 1 activity extractor for an INSOMNIA memory subsystem.
|
||||
|
||||
Your single job: read the supplied conversation slice and emit a structured JSON record of "what happened" via the `write_extracted` tool. You are not consolidating, summarising, or generating knowledge — that is a later phase's job.
|
||||
|
||||
# Hard rules
|
||||
|
||||
- Call `write_extracted` exactly once. Do not narrate, ask questions, or send any other tool output.
|
||||
- The argument is an object with four arrays: `decisions`, `discussions`, `attempts`, `requests`. Any of them may be empty. If nothing in the slice is worth recording, call `write_extracted({"decisions": [], "discussions": [], "attempts": [], "requests": []})` and stop.
|
||||
- Do NOT include `source`, `session_id`, entry indices, timestamps, or any provenance metadata. The wrapper attaches them mechanically.
|
||||
- Do NOT add free-form commentary, summaries, or explanatory prose outside the schema fields.
|
||||
|
||||
# Extraction guidance
|
||||
|
||||
- `decisions`: judgements made during the slice. Each entry needs `options` (the alternatives considered), `chosen` (what was picked), and `rationale` (why).
|
||||
- `discussions`: topics that were debated. `topic` plus `points` (the considerations raised). Open / unresolved discussions are valid.
|
||||
- `attempts`: things that were tried. `action`, `result`, and a `succeeded` boolean. Partial success is `false` with the result text describing the partial outcome.
|
||||
- `requests`: structured summaries of user submissions. `intent` (what the user wants), optional `target` (file / module / feature), and a one-line `summary`.
|
||||
|
||||
# Quality bar
|
||||
|
||||
- Drop one-off chit-chat, shallow questions, and turn-by-turn progress noise. Keep entries with long-term reference value.
|
||||
- Do not duplicate content already captured by static project docs (AGENTS.md, plan documents) — those are not "what happened in this slice".
|
||||
- Prefer concise, fact-shaped strings. Do not pad rationale or summary fields.
|
||||
|
||||
When you have produced the JSON, call `write_extracted` and end the turn. No follow-up text.
|
||||
"#;
|
||||
+13
-3
@@ -1549,7 +1549,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
.unwrap_or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_INPUT_TOKENS);
|
||||
|
||||
let client = self.build_extractor_client(memory_cfg)?;
|
||||
let mut extract_worker = Worker::new(client).system_prompt(extract::EXTRACT_SYSTEM_PROMPT);
|
||||
let extract_system_prompt = self
|
||||
.prompts
|
||||
.memory_extract_system()
|
||||
.map_err(PodError::PromptCatalog)?;
|
||||
let mut extract_worker = Worker::new(client).system_prompt(extract_system_prompt);
|
||||
extract_worker.set_cache_key(Some(self.session_id.to_string()));
|
||||
|
||||
// Cumulative input-token meter + interceptor (mirror of
|
||||
@@ -1742,8 +1746,14 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let mut worker =
|
||||
Worker::new(client).system_prompt(consolidate::CONSOLIDATION_SYSTEM_PROMPT);
|
||||
let consolidation_system_prompt = match self.prompts.memory_consolidation_system() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
lock.release_only();
|
||||
return Err(PodError::PromptCatalog(e));
|
||||
}
|
||||
};
|
||||
let mut worker = Worker::new(client).system_prompt(consolidation_system_prompt);
|
||||
worker.set_cache_key(Some(self.session_id.to_string()));
|
||||
|
||||
// Memory tools are self-contained — they bypass ScopedFs and write
|
||||
|
||||
@@ -61,6 +61,10 @@ const INTERNAL_TOML: &str = include_str!("../../../../resources/prompts/internal
|
||||
pub enum PodPrompt {
|
||||
/// System prompt of the compaction (summary) Worker.
|
||||
CompactSystem,
|
||||
/// System prompt of the memory Phase 1 (extract) Worker.
|
||||
MemoryExtractSystem,
|
||||
/// System prompt of the memory Phase 2 (consolidation + tidy) Worker.
|
||||
MemoryConsolidationSystem,
|
||||
/// Wrapper around an incoming `Method::Notify` message injected into
|
||||
/// the next LLM request context as a transient system message.
|
||||
NotifyWrapper,
|
||||
@@ -89,6 +93,8 @@ impl PodPrompt {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::CompactSystem => "compact_system",
|
||||
Self::MemoryExtractSystem => "memory_extract_system",
|
||||
Self::MemoryConsolidationSystem => "memory_consolidation_system",
|
||||
Self::NotifyWrapper => "notify_wrapper",
|
||||
Self::InterruptToolResultSummary => "interrupt_tool_result_summary",
|
||||
Self::InterruptSystemNote => "interrupt_system_note",
|
||||
@@ -104,6 +110,8 @@ impl PodPrompt {
|
||||
/// `INTERNAL_KEYS` (generated by `build.rs`).
|
||||
pub const ALL: &'static [PodPrompt] = &[
|
||||
PodPrompt::CompactSystem,
|
||||
PodPrompt::MemoryExtractSystem,
|
||||
PodPrompt::MemoryConsolidationSystem,
|
||||
PodPrompt::NotifyWrapper,
|
||||
PodPrompt::InterruptToolResultSummary,
|
||||
PodPrompt::InterruptSystemNote,
|
||||
@@ -115,6 +123,8 @@ impl PodPrompt {
|
||||
|
||||
pub const KEYS: &'static [&'static str] = &[
|
||||
"compact_system",
|
||||
"memory_extract_system",
|
||||
"memory_consolidation_system",
|
||||
"notify_wrapper",
|
||||
"interrupt_tool_result_summary",
|
||||
"interrupt_system_note",
|
||||
@@ -301,6 +311,16 @@ impl PromptCatalog {
|
||||
self.render(PodPrompt::CompactSystem, Value::UNDEFINED)
|
||||
}
|
||||
|
||||
/// Render `PodPrompt::MemoryExtractSystem` (no inputs).
|
||||
pub fn memory_extract_system(&self) -> Result<String, CatalogError> {
|
||||
self.render(PodPrompt::MemoryExtractSystem, Value::UNDEFINED)
|
||||
}
|
||||
|
||||
/// Render `PodPrompt::MemoryConsolidationSystem` (no inputs).
|
||||
pub fn memory_consolidation_system(&self) -> Result<String, CatalogError> {
|
||||
self.render(PodPrompt::MemoryConsolidationSystem, Value::UNDEFINED)
|
||||
}
|
||||
|
||||
/// Render `PodPrompt::NotifyWrapper` with `{{ message }}`.
|
||||
pub fn notify_wrapper(&self, message: &str) -> Result<String, CatalogError> {
|
||||
self.render(PodPrompt::NotifyWrapper, single("message", message))
|
||||
|
||||
@@ -570,6 +570,64 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
|
||||
assert!(last_item_text.contains("not a blocking request"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pod_event_turn_ended_while_idle_auto_starts_turn_and_injects_system_message() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let client_for_assert = client.clone();
|
||||
let pod = make_pod(client).await;
|
||||
let handle = spawn_controller(pod).await;
|
||||
let mut rx = handle.subscribe();
|
||||
|
||||
handle
|
||||
.send(Method::PodEvent(protocol::PodEvent::TurnEnded {
|
||||
pod_name: "child".into(),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut saw_turn_end = false;
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = rx.recv() => {
|
||||
match event {
|
||||
Ok(Event::TurnEnd { .. }) => { saw_turn_end = true; break; }
|
||||
Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep_until(deadline) => break,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_turn_end,
|
||||
"PodEvent::TurnEnded on idle Pod should auto-start a turn"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
assert_eq!(handle.shared_state.get_status(), PodStatus::Idle);
|
||||
|
||||
let requests = client_for_assert.captured_requests();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"auto-kick should issue exactly one LLM request"
|
||||
);
|
||||
let last_item_text = requests[0]
|
||||
.items
|
||||
.last()
|
||||
.and_then(|i| i.as_text())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(
|
||||
last_item_text.contains("[Notification]"),
|
||||
"injected system message missing, got: {last_item_text:?}"
|
||||
);
|
||||
assert!(
|
||||
last_item_text.contains("child") && last_item_text.contains("finished a turn"),
|
||||
"rendered TurnEnded text missing, got: {last_item_text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_while_running_does_not_emit_already_running_error() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
@@ -669,6 +727,61 @@ async fn socket_run_receives_events() {
|
||||
assert!(saw_turn_end, "should see turn_end via socket");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn socket_pod_event_turn_ended_while_idle_auto_starts_turn() {
|
||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let pod = make_pod(client).await;
|
||||
let handle = spawn_controller(pod).await;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
let sock_path = handle.runtime_dir.socket_path();
|
||||
let stream = UnixStream::connect(&sock_path).await.unwrap();
|
||||
let (reader, writer) = stream.into_split();
|
||||
let mut reader = JsonLineReader::new(reader);
|
||||
let mut writer = JsonLineWriter::new(writer);
|
||||
|
||||
writer
|
||||
.write(&Method::PodEvent(protocol::PodEvent::TurnEnded {
|
||||
pod_name: "child".into(),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut saw_turn_start = false;
|
||||
let mut saw_turn_end = false;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = reader.next::<Event>() => {
|
||||
match event {
|
||||
Ok(Some(Event::TurnStart { .. })) => saw_turn_start = true,
|
||||
Ok(Some(Event::TurnEnd { .. })) => {
|
||||
saw_turn_end = true;
|
||||
break;
|
||||
}
|
||||
Ok(None) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep_until(deadline) => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
saw_turn_start,
|
||||
"PodEvent::TurnEnded via socket should auto-start a turn"
|
||||
);
|
||||
assert!(
|
||||
saw_turn_end,
|
||||
"auto-triggered turn should reach turn_end via socket"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn socket_invalid_method_returns_error() {
|
||||
use protocol::stream::JsonLineReader;
|
||||
|
||||
Reference in New Issue
Block a user