Skip to content

Commit f83ec8d

Browse files
authored
feat(agent-memory): align recall and persona contracts (#1460)
* feat(agent-memory): align recall and persona contracts - Add optional exact-agent recall while preserving global-only behavior. - Improve candidate previews and enforce the deployed startup budget. - Document explicit Claude persona settings consumption. * fixup! feat(agent-memory): align recall and persona contracts * fixup! feat(agent-memory): align recall and persona contracts * fixup! fixup! feat(agent-memory): align recall and persona contracts * fixup! fixup! feat(agent-memory): align recall and persona contracts * fixup! fixup! feat(agent-memory): align recall and persona contracts * fixup! fixup! feat(agent-memory): align recall and persona contracts
1 parent ac1f1f9 commit f83ec8d

15 files changed

Lines changed: 815 additions & 86 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

THIRD_PARTY_LICENSES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
This file documents third-party Rust crate licenses used by this workspace.
44

55
- Data source: `cargo metadata --format-version 1 --locked`
6-
- Cargo.lock SHA256: `a492bbfdc2631bd3efa5c0e92f636d8f3e28634638a1237482fa20760ae2902d`
6+
- Cargo.lock SHA256: `561dd7d1087fdd4a2eadc3013c60218ef66db6d2eb3e7c8bd69b7500e02b11c8`
77
- Third-party crates (`source != null`): 487
88
- Workspace crates (`source == null`, excluded below): 46
99

THIRD_PARTY_NOTICES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
This file documents third-party notice-file discovery for Rust crates used by this workspace.
44

55
- Data source: `cargo metadata --format-version 1 --locked`
6-
- Cargo.lock SHA256: `a492bbfdc2631bd3efa5c0e92f636d8f3e28634638a1237482fa20760ae2902d`
6+
- Cargo.lock SHA256: `561dd7d1087fdd4a2eadc3013c60218ef66db6d2eb3e7c8bd69b7500e02b11c8`
77
- Third-party crates (`source != null`): 487
88

99
## Notice Extraction Policy

crates/agent-hook/docs/specs/agent-hook-v1.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,9 @@ rule_id asc)`. A duplicate rule ID is invalid. Decision precedence is
323323
`block > transform-conflict > transform > warn/context > allow`.
324324

325325
Multiple context values concatenate in rule order with a 16 KiB aggregate
326-
limit. Identical replacements coalesce. Different replacements are an explicit
326+
limit. Provider-native rendering uses that complete aggregate even when the
327+
first context rule supplied an otherwise reusable native envelope. Identical
328+
replacements coalesce. Different replacements are an explicit
327329
`transform-conflict` block; transforms never compose implicitly. Failure
328330
posture is typed per rule (`open`, `warn`, or `closed`), while locked privacy,
329331
writer, transaction, and recovery rules must be `closed`.

crates/agent-hook/src/adapter.rs

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ pub fn normalize(
157157
}
158158

159159
pub fn render_provider(decision: &NormalizedDecision) -> Result<String, HookError> {
160-
if let Some(output) = decision.provider_output.as_ref() {
161-
return serde_json::to_string(output).map_err(|_| {
160+
if let Some(output) = provider_output_with_aggregate_context(decision) {
161+
return serde_json::to_string(&output).map_err(|_| {
162162
HookError::runtime(
163163
"provider-output-render-failed",
164164
"provider output could not be rendered",
@@ -203,6 +203,51 @@ pub fn render_provider(decision: &NormalizedDecision) -> Result<String, HookErro
203203
})
204204
}
205205

206+
fn provider_output_with_aggregate_context(decision: &NormalizedDecision) -> Option<Value> {
207+
let mut output = decision.provider_output.clone()?;
208+
if !matches!(
209+
decision.action,
210+
DecisionAction::Context | DecisionAction::Warn
211+
) {
212+
return Some(output);
213+
}
214+
let Some(context) = decision.context.as_deref() else {
215+
return Some(output);
216+
};
217+
218+
let Some(root) = output.as_object_mut() else {
219+
return Some(json!({
220+
"hookSpecificOutput": {
221+
"hookEventName": decision.event,
222+
"additionalContext": context,
223+
}
224+
}));
225+
};
226+
let has_top_level_context = root.contains_key("additionalContext");
227+
if let Some(hook_output) = root
228+
.get_mut("hookSpecificOutput")
229+
.and_then(Value::as_object_mut)
230+
{
231+
hook_output.insert("additionalContext".to_string(), json!(context));
232+
hook_output
233+
.entry("hookEventName".to_string())
234+
.or_insert_with(|| json!(decision.event));
235+
if has_top_level_context {
236+
root.insert("additionalContext".to_string(), json!(context));
237+
}
238+
} else if has_top_level_context {
239+
root.insert("additionalContext".to_string(), json!(context));
240+
} else {
241+
return Some(json!({
242+
"hookSpecificOutput": {
243+
"hookEventName": decision.event,
244+
"additionalContext": context,
245+
}
246+
}));
247+
}
248+
Some(output)
249+
}
250+
206251
pub fn render_provider_error(
207252
product: Product,
208253
event: &str,
@@ -810,3 +855,127 @@ fn parse_provider_json(input: &[u8]) -> Result<Value, HookError> {
810855
}
811856
})
812857
}
858+
859+
#[cfg(test)]
860+
mod tests {
861+
use super::*;
862+
use crate::model::{DecisionReason, ShadowObservation};
863+
864+
#[test]
865+
fn provider_render_uses_the_full_aggregated_context() {
866+
for product in [Product::Codex, Product::Claude] {
867+
let decision = NormalizedDecision {
868+
schema_version: "agent-hook.decision.v1".to_string(),
869+
request_id: "request:test".to_string(),
870+
product,
871+
event: "UserPromptSubmit".to_string(),
872+
action: DecisionAction::Context,
873+
reasons: vec![DecisionReason {
874+
rule_id: "fixture.context".to_string(),
875+
code: "fixture-context".to_string(),
876+
disposition: "context".to_string(),
877+
}],
878+
context: Some("first context\nsecond context".to_string()),
879+
replacement: None,
880+
shadow: Vec::<ShadowObservation>::new(),
881+
config_digest: "sha256:config".to_string(),
882+
policy_digest: "sha256:policy".to_string(),
883+
recovery_applied: false,
884+
provider_output: Some(json!({
885+
"hookSpecificOutput": {
886+
"hookEventName": "UserPromptSubmit",
887+
"additionalContext": "first context",
888+
},
889+
"suppressOutput": true,
890+
})),
891+
};
892+
893+
let rendered: Value =
894+
serde_json::from_str(&render_provider(&decision).expect("provider output"))
895+
.expect("provider JSON");
896+
assert_eq!(
897+
rendered["hookSpecificOutput"]["additionalContext"],
898+
"first context\nsecond context"
899+
);
900+
assert_eq!(rendered["suppressOutput"], true);
901+
}
902+
}
903+
904+
#[test]
905+
fn provider_render_preserves_native_envelopes_without_aggregate_context() {
906+
for product in [Product::Codex, Product::Claude] {
907+
for action in [
908+
DecisionAction::Allow,
909+
DecisionAction::Block,
910+
DecisionAction::Transform,
911+
] {
912+
let provider_output = json!({
913+
"decision": "provider-native",
914+
"reason": "preserve me",
915+
"suppressOutput": true,
916+
"providerExtension": {"product": product.as_str()},
917+
});
918+
let decision = NormalizedDecision {
919+
schema_version: "agent-hook.decision.v1".to_string(),
920+
request_id: "request:provider-preservation".to_string(),
921+
product,
922+
event: "PreToolUse".to_string(),
923+
action,
924+
reasons: Vec::new(),
925+
context: None,
926+
replacement: None,
927+
shadow: Vec::<ShadowObservation>::new(),
928+
config_digest: "sha256:config".to_string(),
929+
policy_digest: "sha256:policy".to_string(),
930+
recovery_applied: false,
931+
provider_output: Some(provider_output.clone()),
932+
};
933+
934+
let rendered: Value =
935+
serde_json::from_str(&render_provider(&decision).expect("provider output"))
936+
.expect("provider JSON");
937+
assert_eq!(rendered, provider_output);
938+
}
939+
}
940+
}
941+
942+
#[test]
943+
fn provider_render_synchronizes_mixed_accepted_context_locations() {
944+
for product in [Product::Codex, Product::Claude] {
945+
let decision = NormalizedDecision {
946+
schema_version: "agent-hook.decision.v1".to_string(),
947+
request_id: "request:mixed-context".to_string(),
948+
product,
949+
event: "UserPromptSubmit".to_string(),
950+
action: DecisionAction::Context,
951+
reasons: Vec::new(),
952+
context: Some("first context\nsecond context".to_string()),
953+
replacement: None,
954+
shadow: Vec::<ShadowObservation>::new(),
955+
config_digest: "sha256:config".to_string(),
956+
policy_digest: "sha256:policy".to_string(),
957+
recovery_applied: false,
958+
provider_output: Some(json!({
959+
"additionalContext": "first context",
960+
"hookSpecificOutput": {
961+
"hookEventName": "UserPromptSubmit",
962+
},
963+
"suppressOutput": true,
964+
})),
965+
};
966+
967+
let rendered: Value =
968+
serde_json::from_str(&render_provider(&decision).expect("provider output"))
969+
.expect("provider JSON");
970+
assert_eq!(
971+
rendered["hookSpecificOutput"]["additionalContext"],
972+
"first context\nsecond context"
973+
);
974+
assert_eq!(
975+
rendered["additionalContext"],
976+
"first context\nsecond context"
977+
);
978+
assert_eq!(rendered["suppressOutput"], true);
979+
}
980+
}
981+
}

crates/agent-memory/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ clap = { workspace = true }
2020
clap_complete = { workspace = true }
2121
nils-build-info = { version = "1.26.3", path = "../nils-build-info" }
2222
nils-common = { version = "1.26.3", path = "../nils-common", package = "nils-common" }
23+
serde = { workspace = true }
2324
serde_json = { workspace = true }
2425

2526
[dev-dependencies]

crates/agent-memory/README.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ agent-memory add [SCOPE] --name <slug> --type <t> --description <text> \
4343
agent-memory list [SCOPE] [--type <t>] [--format text|json]
4444
agent-memory search <term> [SCOPE] [--all] [--format text|json]
4545
agent-memory recall startup [--max-bytes <bytes>] [--format text|json]
46-
agent-memory recall on-demand <term> [--format text|json]
46+
agent-memory recall on-demand <term> [--agent <id>] [--format text|json]
4747
agent-memory recall candidates [producer] [--format text|json]
4848
agent-memory candidate add <producer> --name <slug> \
4949
[--title <text>] [--hook <text>] [--body <text>|-] \
@@ -110,21 +110,35 @@ matches and `1` when there are none.
110110
## Recall profiles
111111

112112
`recall startup` reads only `profiles/startup/MEMORY.md`, treats the payload as
113-
untrusted memory data, and fails closed when the file exceeds 3,072 bytes by
114-
default. `--max-bytes` can set a stricter or explicitly configured boundary.
113+
untrusted memory data, and rejects a file that exceeds the deployed 768-byte
114+
transport budget by default. `--max-bytes` can set an explicitly configured
115+
boundary.
115116
It never falls back to `global/MEMORY.md`.
116117

117-
`recall on-demand <term>` searches curated `global/*.md` notes only and does
118-
not emit the full global index. `recall candidates [producer]` lists opaque
119-
proposal files under producer-isolated candidate roots and labels the result
120-
untrusted.
118+
`recall on-demand <term>` searches curated `global/*.md` notes and does not emit
119+
the full global index. `--agent <id>` additionally includes only the exact
120+
registered `agents/<id>/*.md` scope; candidate, archive, profile, and persona
121+
content remain excluded. Omitting `--agent` preserves the global-only contract.
122+
This manual query returns the complete curated match set; automatic provider
123+
injection uses only the separately bounded startup profile. The implementation
124+
does not retain a second text hit collection or duplicate the JSON value tree.
125+
`recall candidates [producer]` lists opaque proposal files under
126+
producer-isolated candidate roots and labels the result untrusted.
121127

122128
## Candidate lifecycle
123129

124130
`candidate add` creates or reuses `candidates/<producer>/`, writes one opaque
125131
proposal file, and updates its candidate index. Candidate bodies do not need
126132
canonical frontmatter because provider-native memory may use its own format.
127133
Candidate roots, indexes, source files, and body-file inputs reject symlinks.
134+
Candidate listing uses an optional recognizable frontmatter description, then
135+
the first body line, for its bounded preview; otherwise it preserves the opaque
136+
first-non-empty-line fallback.
137+
138+
`init-persona` writes the isolated auto-memory path to
139+
`personas/<id>/.claude/settings.local.json`. Persona launchers must pass that
140+
file explicitly with `claude --settings`; the filename alone is not a promise
141+
that Claude will honor user-scope-only settings keys.
128142

129143
`candidate promote` is non-mutating unless `--apply` is present. The preview
130144
validates the producer, source, destination, canonical type, and both indexes,

crates/agent-memory/docs/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,16 @@ Beyond the original shell contract, the Rust CLI adds:
3737
the default text output is unchanged.
3838
- `search <term> [SCOPE] [--all]` — case-insensitive substring search over note
3939
frontmatter and bodies, returning `scope/file:line: text`.
40-
- `recall startup|on-demand|candidates` — exposes bounded startup routing,
41-
curated term recall, and explicitly untrusted proposal listing as separate
42-
profiles.
40+
- `recall startup|on-demand|candidates` — exposes 768-byte bounded startup
41+
routing, curated global term recall with an optional exact `--agent` scope,
42+
and explicitly untrusted proposal listing as separate profiles.
4343
- `candidate add|list|promote` — isolates proposal writers by producer and
4444
provides an explicit dry-run/apply promotion transaction into curated
4545
`global/` memory. Promotion requires explicit session provenance, preserves
4646
supported global-directory symlinks, removes exact native-index filename
4747
references, and reports incomplete rollback without deleting recovery
48-
backups.
48+
backups. Listing prefers an optional frontmatter description or body preview
49+
while retaining opaque fallback behavior.
4950
- `archive list|search` — explicitly queries historical superseded notes that
5051
are structurally excluded from active recall, search, checks, and completion.
5152
- `archive retire <slug> ... [--apply]` — dry-run-first, rollback-safe movement

crates/agent-memory/src/candidate.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,14 @@ pub(crate) fn print_list(
133133
let contents = fs::read_to_string(&path).map_err(|err| {
134134
CliError::runtime(format!("failed to read {}: {err}", display_path(&path)))
135135
})?;
136-
let preview = contents
137-
.lines()
138-
.map(str::trim)
139-
.find(|line| !line.is_empty())
140-
.unwrap_or("")
141-
.chars()
142-
.take(160)
143-
.collect();
136+
let preview_source = frontmatter::candidate_preview(&contents).unwrap_or_else(|| {
137+
contents
138+
.lines()
139+
.map(str::trim)
140+
.find(|line| !line.is_empty())
141+
.unwrap_or("")
142+
});
143+
let preview = preview_source.chars().take(160).collect();
144144
let mtime = metadata
145145
.modified()
146146
.ok()

crates/agent-memory/src/cli.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ pub struct RecallArgs {
183183
pub enum RecallCommand {
184184
/// Print the bounded profiles/startup index.
185185
Startup(RecallStartupArgs),
186-
/// Search curated global notes only.
186+
/// Search curated global notes, optionally including one agent scope.
187187
OnDemand(RecallOnDemandArgs),
188188
/// List untrusted candidate notes, optionally for one producer.
189189
Candidates(RecallCandidatesArgs),
@@ -192,7 +192,7 @@ pub enum RecallCommand {
192192
#[derive(Debug, Args)]
193193
pub struct RecallStartupArgs {
194194
/// Maximum allowed startup index size.
195-
#[arg(long, value_name = "BYTES", default_value_t = 3072)]
195+
#[arg(long, value_name = "BYTES", default_value_t = 768)]
196196
pub max_bytes: usize,
197197
/// Output format.
198198
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
@@ -207,6 +207,9 @@ pub struct RecallOnDemandArgs {
207207
/// Term to find in curated global note content.
208208
#[arg(value_name = "TERM")]
209209
pub term: String,
210+
/// Also search one exact registered non-Claude agent scope.
211+
#[arg(long, value_name = "ID")]
212+
pub agent: Option<String>,
210213
/// Output format.
211214
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
212215
pub format: OutputFormat,

0 commit comments

Comments
 (0)