Skip to content

Commit 02b3e7f

Browse files
v0.17.0 apiproxy: omc_proxy_remember + omc_proxy_recall (named memory MCP tools)
Two new proxy-managed MCP tools injected into every request alongside omc_proxy_expand_ref: omc_proxy_remember(key, value) Stores 'value' in the proxy MemoryStore and records the mapping key → hash in AppState.named_refs. The LLM can use any human- readable string as the key. Returns the hash so the LLM can also reference the content by hash later. omc_proxy_recall(key) Looks up 'key' in named_refs, fetches the content from MemoryStore, and returns it as a tool_result. Returns an error string if the key is unknown. Both calls are resolved entirely within the proxy loop — no round-trips to Anthropic. stats/_stats gains remember_calls + recall_calls counters. Foundation for LLM-to-LLM shared state: agents on the same proxy instance can share content by writing to a named key and reading from it. Tests: 20/20 (+4 new: all_proxy_tools_are_injected, collect_proxy_calls_parses_remember_and_recall, non_proxy_tools_return_empty_vec, mixed_proxy_and_non_proxy_tools_return_empty_vec)
1 parent 251895e commit 02b3e7f

1 file changed

Lines changed: 217 additions & 49 deletions

File tree

omnimcode-apiproxy/src/main.rs

Lines changed: 217 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ use std::sync::Arc;
4949
use tracing::{debug, info, warn};
5050

5151
const PROXY_CACHE_NAMESPACE: &str = "_apiproxy_cache";
52-
const EXPAND_TOOL_NAME: &str = "omc_proxy_expand_ref";
52+
const EXPAND_TOOL_NAME: &str = "omc_proxy_expand_ref";
53+
const REMEMBER_TOOL_NAME: &str = "omc_proxy_remember";
54+
const RECALL_TOOL_NAME: &str = "omc_proxy_recall";
5355

5456
#[derive(Parser, Debug, Clone)]
5557
#[command(name = "omnimcode-apiproxy", version = env!("CARGO_PKG_VERSION"))]
@@ -100,6 +102,10 @@ struct RewriteStats {
100102
/// Streaming requests pass through rewritten (request side) but
101103
/// the response is piped directly rather than buffered.
102104
streaming_passthrough: u64,
105+
/// omc_proxy_remember calls resolved by the proxy.
106+
remember_calls: u64,
107+
/// omc_proxy_recall calls resolved by the proxy.
108+
recall_calls: u64,
103109
}
104110

105111
/// Per-conversation state the proxy remembers across turns. Key is a stable
@@ -128,6 +134,8 @@ struct AppState {
128134
delta_min_bytes: usize,
129135
http: reqwest::Client,
130136
store: Arc<MemoryStore>,
137+
/// Named key→hash index for omc_proxy_remember / omc_proxy_recall.
138+
named_refs: Arc<std::sync::Mutex<std::collections::HashMap<String, i64>>>,
131139
stats: Arc<std::sync::Mutex<RewriteStats>>,
132140
/// v0.14.6: per-conversation state, keyed by `conversation_id` (hash of
133141
/// system + tools + first user message). Bounded to ~256 conversations
@@ -174,6 +182,7 @@ async fn main() -> Result<()> {
174182
.build()?,
175183
store: Arc::new(MemoryStore::from_env()),
176184
stats: Arc::new(std::sync::Mutex::new(RewriteStats::default())),
185+
named_refs: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
177186
conversations: Arc::new(std::sync::Mutex::new(
178187
std::collections::HashMap::new())),
179188
prefix_index: Arc::new(std::sync::Mutex::new(
@@ -305,16 +314,14 @@ async fn handle_with_expand_loop(
305314
Err(_) => return rebuild_response(status, &resp_headers, resp_body),
306315
};
307316

308-
// Look for an exclusive expand tool_use
309-
let expand_calls = collect_sole_expand_tool_uses(&resp_json);
310-
if expand_calls.is_empty() {
317+
// Resolve any proxy-managed tool calls transparently
318+
let proxy_calls = collect_proxy_tool_calls(&resp_json);
319+
if proxy_calls.is_empty() {
311320
return rebuild_response(status, &resp_headers, resp_body);
312321
}
313-
info!("round {}: auto-resolving {} expand tool_use(s)",
314-
round + 1, expand_calls.len());
322+
info!("round {}: auto-resolving {} proxy tool_call(s)",
323+
round + 1, proxy_calls.len());
315324

316-
// Build follow-up request: previous messages + assistant response
317-
// (rewritten through marker logic) + new user turn with tool_result
318325
let mut next_req: Value = match serde_json::from_slice(&current_body) {
319326
Ok(v) => v,
320327
Err(_) => return rebuild_response(status, &resp_headers, resp_body),
@@ -324,19 +331,47 @@ async fn handle_with_expand_loop(
324331
let Some(messages) = messages else {
325332
return rebuild_response(status, &resp_headers, resp_body);
326333
};
327-
// Append the assistant turn (the upstream's response) verbatim
328334
if let Some(asst_content) = resp_json.get("content").cloned() {
329335
messages.push(json!({"role": "assistant", "content": asst_content}));
330336
}
331-
// Append a user turn with one tool_result per expand call
337+
332338
let mut tool_results: Vec<Value> = Vec::new();
333-
for (tool_use_id, hash_str) in &expand_calls {
334-
let body_text = lookup_expand(&hash_str, &state).unwrap_or_else(|e|
335-
format!("[apiproxy: expand cache miss for {}: {}]", hash_str, e));
339+
for call in &proxy_calls {
340+
let (tool_use_id, result_text) = match call {
341+
ProxyCall::ExpandRef { id, hash_str } => {
342+
let body_text = lookup_expand(hash_str, &state).unwrap_or_else(|e|
343+
format!("[apiproxy: expand miss for {}: {}]", hash_str, e));
344+
(id.clone(), body_text)
345+
}
346+
ProxyCall::Remember { id, key, value } => {
347+
let hash = state.store
348+
.store(PROXY_CACHE_NAMESPACE, value)
349+
.unwrap_or(-1);
350+
state.named_refs.lock().unwrap().insert(key.clone(), hash);
351+
state.stats.lock().unwrap().remember_calls += 1;
352+
info!("omc_proxy_remember: stored key={:?} hash={}", key, hash);
353+
(id.clone(), format!("Stored under key {:?} (hash {}).", key, hash))
354+
}
355+
ProxyCall::Recall { id, key } => {
356+
let result = {
357+
let map = state.named_refs.lock().unwrap();
358+
map.get(key.as_str()).copied()
359+
};
360+
let text = match result {
361+
Some(h) => state.store.recall(Some(PROXY_CACHE_NAMESPACE), h)
362+
.ok().flatten()
363+
.unwrap_or_else(|| format!("[apiproxy: recall cache miss for key {:?}]", key)),
364+
None => format!("[apiproxy: no memory stored under key {:?}]", key),
365+
};
366+
state.stats.lock().unwrap().recall_calls += 1;
367+
info!("omc_proxy_recall: key={:?}", key);
368+
(id.clone(), text)
369+
}
370+
};
336371
tool_results.push(json!({
337372
"type": "tool_result",
338373
"tool_use_id": tool_use_id,
339-
"content": body_text,
374+
"content": result_text,
340375
}));
341376
}
342377
messages.push(json!({"role": "user", "content": tool_results}));
@@ -348,35 +383,52 @@ async fn handle_with_expand_loop(
348383
"apiproxy: expand loop limit exceeded")
349384
}
350385

351-
/// If the response's `content` array contains exactly one tool_use AND it
352-
/// is for `omc_proxy_expand_ref`, return its (id, hash_str). Returning
353-
/// multiple results means there were multiple expand calls in a row, which
354-
/// also auto-resolves. Returns empty Vec for mixed tool_use (skip
355-
/// interception, let client handle) or no tool_use at all.
356-
fn collect_sole_expand_tool_uses(resp: &Value) -> Vec<(String, String)> {
386+
/// If the response's `content` array contains only proxy-managed tool_uses
387+
/// (expand_ref / remember / recall), return them all. If there are any
388+
/// non-proxy tool_uses the client must handle, return empty vec so the
389+
/// response passes through unchanged.
390+
#[derive(Debug)]
391+
enum ProxyCall {
392+
ExpandRef { id: String, hash_str: String },
393+
Remember { id: String, key: String, value: String },
394+
Recall { id: String, key: String },
395+
}
396+
397+
fn collect_proxy_tool_calls(resp: &Value) -> Vec<ProxyCall> {
357398
let Some(content) = resp.get("content").and_then(Value::as_array) else {
358399
return vec![];
359400
};
360-
let mut expand = Vec::new();
361-
let mut has_other_tool_use = false;
401+
let mut calls = Vec::new();
362402
for block in content {
363-
if block.get("type").and_then(Value::as_str) == Some("tool_use") {
364-
let name = block.get("name").and_then(Value::as_str).unwrap_or("");
365-
if name == EXPAND_TOOL_NAME {
366-
let id = block.get("id").and_then(Value::as_str)
403+
if block.get("type").and_then(Value::as_str) != Some("tool_use") { continue; }
404+
let name = block.get("name").and_then(Value::as_str).unwrap_or("");
405+
let id = block.get("id").and_then(Value::as_str).unwrap_or("").to_string();
406+
let inp = block.get("input").cloned().unwrap_or(Value::Null);
407+
match name {
408+
n if n == EXPAND_TOOL_NAME => {
409+
let hash_str = inp.get("hash_str").and_then(Value::as_str)
367410
.unwrap_or("").to_string();
368-
let hash = block.get("input")
369-
.and_then(|i| i.get("hash_str"))
370-
.and_then(Value::as_str).unwrap_or("").to_string();
371-
if !id.is_empty() && !hash.is_empty() {
372-
expand.push((id, hash));
373-
}
374-
} else {
375-
has_other_tool_use = true;
411+
if !id.is_empty() && !hash_str.is_empty() {
412+
calls.push(ProxyCall::ExpandRef { id, hash_str });
413+
} else { return vec![]; } // malformed — pass through
376414
}
415+
n if n == REMEMBER_TOOL_NAME => {
416+
let key = inp.get("key").and_then(Value::as_str).unwrap_or("").to_string();
417+
let value = inp.get("value").and_then(Value::as_str).unwrap_or("").to_string();
418+
if !id.is_empty() && !key.is_empty() {
419+
calls.push(ProxyCall::Remember { id, key, value });
420+
} else { return vec![]; }
421+
}
422+
n if n == RECALL_TOOL_NAME => {
423+
let key = inp.get("key").and_then(Value::as_str).unwrap_or("").to_string();
424+
if !id.is_empty() && !key.is_empty() {
425+
calls.push(ProxyCall::Recall { id, key });
426+
} else { return vec![]; }
427+
}
428+
_ => return vec![], // non-proxy tool → client must handle
377429
}
378430
}
379-
if has_other_tool_use { vec![] } else { expand }
431+
calls
380432
}
381433

382434
fn lookup_expand(hash_str: &str, state: &AppState) -> Result<String> {
@@ -523,6 +575,8 @@ async fn stats_endpoint(State(state): State<AppState>) -> Response {
523575
"conversations_seen": s.conversation_count,
524576
"delta_stores_attempted": s.delta_stores_attempted,
525577
"streaming_passthrough": s.streaming_passthrough,
578+
"remember_calls": s.remember_calls,
579+
"recall_calls": s.recall_calls,
526580
})).unwrap();
527581
(StatusCode::OK,
528582
[(axum::http::header::CONTENT_TYPE, HeaderValue::from_static("application/json"))],
@@ -1060,10 +1114,25 @@ fn try_delta_store(text: &str, state: &AppState) -> Option<i64> {
10601114
Some(result)
10611115
}
10621116

1063-
/// Add the omc_proxy_expand_ref tool to the request's tools array so the
1064-
/// LLM has a way to retrieve full bytes for any marker it cares about.
1065-
fn inject_expand_tool(req: &mut Value) {
1066-
let tool = json!({
1117+
/// Inject all proxy-owned tools into the request's tools array.
1118+
/// Currently: omc_proxy_expand_ref, omc_proxy_remember, omc_proxy_recall.
1119+
/// We avoid double-injection by checking for expand_ref (the sentinel tool).
1120+
fn inject_proxy_tools(req: &mut Value) {
1121+
let tools_arr = match req.get_mut("tools") {
1122+
Some(Value::Array(a)) => {
1123+
if a.iter().any(|t| t.get("name").and_then(Value::as_str) == Some(EXPAND_TOOL_NAME)) {
1124+
return; // already injected
1125+
}
1126+
a
1127+
}
1128+
_ => {
1129+
req["tools"] = Value::Array(vec![]);
1130+
req["tools"].as_array_mut().unwrap()
1131+
}
1132+
};
1133+
1134+
// ── omc_proxy_expand_ref ──────────────────────────────────────────────
1135+
tools_arr.push(json!({
10671136
"name": EXPAND_TOOL_NAME,
10681137
"description": "Expand an <omc:ref/> marker back to its full text. \
10691138
The proxy replaced large content blocks in your context \
@@ -1080,20 +1149,49 @@ fn inject_expand_tool(req: &mut Value) {
10801149
},
10811150
"required": ["hash_str"]
10821151
}
1083-
});
1084-
match req.get_mut("tools") {
1085-
Some(Value::Array(tools)) => {
1086-
// Don't double-inject if a previous turn already added it.
1087-
let exists = tools.iter().any(|t|
1088-
t.get("name").and_then(Value::as_str) == Some(EXPAND_TOOL_NAME));
1089-
if !exists { tools.push(tool); }
1152+
}));
1153+
1154+
// ── omc_proxy_remember ────────────────────────────────────────────────
1155+
tools_arr.push(json!({
1156+
"name": REMEMBER_TOOL_NAME,
1157+
"description": "Durably store an arbitrary string under a human-readable \
1158+
key. The proxy persists it and you can retrieve it in any \
1159+
future turn (including across sessions) via omc_proxy_recall. \
1160+
Use this to save findings, summaries, or shared state that \
1161+
another agent or future-you will need.",
1162+
"input_schema": {
1163+
"type": "object",
1164+
"properties": {
1165+
"key": { "type": "string",
1166+
"description": "Human-readable name (e.g. 'plan_v2', 'user_prefs')." },
1167+
"value": { "type": "string",
1168+
"description": "Content to store (any UTF-8 text)." }
1169+
},
1170+
"required": ["key", "value"]
10901171
}
1091-
_ => {
1092-
req["tools"] = Value::Array(vec![tool]);
1172+
}));
1173+
1174+
// ── omc_proxy_recall ──────────────────────────────────────────────────
1175+
tools_arr.push(json!({
1176+
"name": RECALL_TOOL_NAME,
1177+
"description": "Retrieve content previously stored with omc_proxy_remember. \
1178+
Returns the stored text, or an error if the key has never \
1179+
been set.",
1180+
"input_schema": {
1181+
"type": "object",
1182+
"properties": {
1183+
"key": { "type": "string",
1184+
"description": "The key passed to a prior omc_proxy_remember call." }
1185+
},
1186+
"required": ["key"]
10931187
}
1094-
}
1188+
}));
10951189
}
10961190

1191+
/// Compatibility shim — callers that used inject_expand_tool still work.
1192+
#[allow(dead_code)]
1193+
fn inject_expand_tool(req: &mut Value) { inject_proxy_tools(req); }
1194+
10971195
#[cfg(test)]
10981196
mod tests {
10991197
use super::*;
@@ -1118,6 +1216,7 @@ mod tests {
11181216
http: reqwest::Client::new(),
11191217
store: Arc::new(MemoryStore::from_env()),
11201218
stats: Arc::new(std::sync::Mutex::new(RewriteStats::default())),
1219+
named_refs: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
11211220
conversations: Arc::new(std::sync::Mutex::new(
11221221
std::collections::HashMap::new())),
11231222
prefix_index: Arc::new(std::sync::Mutex::new(
@@ -1763,4 +1862,73 @@ mod tests {
17631862
assert!(state.stats.lock().unwrap().delta_stores_attempted > 0,
17641863
"expected delta_stores_attempted > 0 with low delta_min_bytes");
17651864
}
1865+
1866+
// ── Feature: proxy tool call parsing (remember / recall) ─────────────────
1867+
1868+
/// collect_proxy_tool_calls parses remember and recall tool_use blocks
1869+
/// from a synthetic LLM response and returns the right ProxyCall variants.
1870+
#[test]
1871+
fn collect_proxy_calls_parses_remember_and_recall() {
1872+
let resp = json!({
1873+
"type": "message", "stop_reason": "tool_use",
1874+
"content": [
1875+
{"type": "tool_use", "id": "tu1", "name": "omc_proxy_remember",
1876+
"input": {"key": "mykey", "value": "stored content"}},
1877+
{"type": "tool_use", "id": "tu2", "name": "omc_proxy_recall",
1878+
"input": {"key": "mykey"}},
1879+
]
1880+
});
1881+
let calls = collect_proxy_tool_calls(&resp);
1882+
assert_eq!(calls.len(), 2, "must parse both calls");
1883+
assert!(matches!(&calls[0], ProxyCall::Remember { key, .. } if key == "mykey"));
1884+
assert!(matches!(&calls[1], ProxyCall::Recall { key, .. } if key == "mykey"));
1885+
}
1886+
1887+
/// Non-proxy tool_uses (e.g. bash) cause collect_proxy_tool_calls to
1888+
/// return an empty vec so the response passes through to the client.
1889+
#[test]
1890+
fn non_proxy_tools_return_empty_vec() {
1891+
let resp = json!({
1892+
"type": "message", "stop_reason": "tool_use",
1893+
"content": [
1894+
{"type": "tool_use", "id": "tu1", "name": "bash",
1895+
"input": {"command": "ls"}},
1896+
]
1897+
});
1898+
assert!(collect_proxy_tool_calls(&resp).is_empty(),
1899+
"non-proxy tool must not be intercepted");
1900+
}
1901+
1902+
/// Mixed proxy + non-proxy: the non-proxy call poisons the batch so the
1903+
/// proxy passes the response through unchanged.
1904+
#[test]
1905+
fn mixed_proxy_and_non_proxy_tools_return_empty_vec() {
1906+
let resp = json!({
1907+
"type": "message", "stop_reason": "tool_use",
1908+
"content": [
1909+
{"type": "tool_use", "id": "tu1", "name": EXPAND_TOOL_NAME,
1910+
"input": {"hash_str": "12345"}},
1911+
{"type": "tool_use", "id": "tu2", "name": "bash",
1912+
"input": {"command": "ls"}},
1913+
]
1914+
});
1915+
assert!(collect_proxy_tool_calls(&resp).is_empty(),
1916+
"mixed batch must pass through so the client handles bash");
1917+
}
1918+
1919+
/// All three proxy tools are injected into outbound requests.
1920+
#[test]
1921+
fn all_proxy_tools_are_injected() {
1922+
let mut req = json!({
1923+
"model": "test", "max_tokens": 10,
1924+
"messages": [{"role": "user", "content": "hi"}]
1925+
});
1926+
inject_proxy_tools(&mut req);
1927+
let tools = req["tools"].as_array().expect("tools array must exist");
1928+
let names: Vec<&str> = tools.iter()
1929+
.filter_map(|t| t["name"].as_str()).collect();
1930+
assert!(names.contains(&EXPAND_TOOL_NAME), "expand_ref must be injected");
1931+
assert!(names.contains(&REMEMBER_TOOL_NAME), "remember must be injected");
1932+
assert!(names.contains(&RECALL_TOOL_NAME), "recall must be injected");
1933+
}
17661934
}

0 commit comments

Comments
 (0)