@@ -49,7 +49,9 @@ use std::sync::Arc;
4949use tracing:: { debug, info, warn} ;
5050
5151const 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
382434fn 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) ]
10981196mod 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