coco-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (400) hide show
  1. coco/__init__.py +3 -0
  2. coco/agents/__init__.py +9 -0
  3. coco/agents/_subagent_transformer.py +307 -0
  4. coco/agents/factory.py +2164 -0
  5. coco/agents/middleware/__init__.py +100 -0
  6. coco/agents/middleware/_execution.py +385 -0
  7. coco/agents/middleware/_redaction.py +454 -0
  8. coco/agents/middleware/_retry.py +134 -0
  9. coco/agents/middleware/_trace_policy.py +61 -0
  10. coco/agents/middleware/context_editing.py +306 -0
  11. coco/agents/middleware/file_search.py +433 -0
  12. coco/agents/middleware/human_in_the_loop.py +680 -0
  13. coco/agents/middleware/internal_call_transformer.py +108 -0
  14. coco/agents/middleware/model_call_limit.py +267 -0
  15. coco/agents/middleware/model_fallback.py +417 -0
  16. coco/agents/middleware/model_retry.py +327 -0
  17. coco/agents/middleware/pii.py +878 -0
  18. coco/agents/middleware/provider_tool_search.py +307 -0
  19. coco/agents/middleware/shell_tool.py +949 -0
  20. coco/agents/middleware/summarization.py +910 -0
  21. coco/agents/middleware/todo.py +357 -0
  22. coco/agents/middleware/tool_call_limit.py +495 -0
  23. coco/agents/middleware/tool_emulator.py +251 -0
  24. coco/agents/middleware/tool_error.py +209 -0
  25. coco/agents/middleware/tool_retry.py +421 -0
  26. coco/agents/middleware/tool_selection.py +515 -0
  27. coco/agents/middleware/types.py +2201 -0
  28. coco/agents/structured_output.py +472 -0
  29. coco/chat_models/__init__.py +7 -0
  30. coco/chat_models/base.py +1082 -0
  31. coco/embeddings/__init__.py +18 -0
  32. coco/embeddings/base.py +279 -0
  33. coco/mcp/__init__.py +36 -0
  34. coco/mcp/adapter.py +245 -0
  35. coco/mcp/elicitation.py +394 -0
  36. coco/mcp/tools.py +307 -0
  37. coco/messages/__init__.py +73 -0
  38. coco/py.typed +0 -0
  39. coco/rate_limiters/__init__.py +13 -0
  40. coco/tools/__init__.py +22 -0
  41. coco/tools/tool_node.py +20 -0
  42. coco_anthropic/__init__.py +15 -0
  43. coco_anthropic/_client_utils.py +81 -0
  44. coco_anthropic/_compat.py +228 -0
  45. coco_anthropic/_sdk_compat.py +124 -0
  46. coco_anthropic/_version.py +3 -0
  47. coco_anthropic/chat_models.py +3349 -0
  48. coco_anthropic/data/__init__.py +1 -0
  49. coco_anthropic/data/_profiles.py +491 -0
  50. coco_anthropic/data/profile_augmentations.toml +69 -0
  51. coco_anthropic/experimental.py +140 -0
  52. coco_anthropic/llms.py +428 -0
  53. coco_anthropic/middleware/__init__.py +25 -0
  54. coco_anthropic/middleware/anthropic_tools.py +1197 -0
  55. coco_anthropic/middleware/bash.py +84 -0
  56. coco_anthropic/middleware/file_search.py +360 -0
  57. coco_anthropic/middleware/prompt_caching.py +276 -0
  58. coco_anthropic/output_parsers.py +93 -0
  59. coco_anthropic/py.typed +0 -0
  60. coco_core/__init__.py +20 -0
  61. coco_core/_api/__init__.py +87 -0
  62. coco_core/_api/beta_decorator.py +266 -0
  63. coco_core/_api/deprecation.py +637 -0
  64. coco_core/_api/internal.py +23 -0
  65. coco_core/_api/path.py +50 -0
  66. coco_core/_import_utils.py +41 -0
  67. coco_core/_protocol.py +625 -0
  68. coco_core/_security/__init__.py +36 -0
  69. coco_core/_security/_exceptions.py +9 -0
  70. coco_core/_security/_policy.py +313 -0
  71. coco_core/_security/_ssrf_protection.py +155 -0
  72. coco_core/_security/_transport.py +254 -0
  73. coco_core/agents.py +258 -0
  74. coco_core/caches.py +272 -0
  75. coco_core/callbacks/__init__.py +132 -0
  76. coco_core/callbacks/base.py +1229 -0
  77. coco_core/callbacks/file.py +266 -0
  78. coco_core/callbacks/manager.py +2826 -0
  79. coco_core/callbacks/stdout.py +123 -0
  80. coco_core/callbacks/streaming_stdout.py +154 -0
  81. coco_core/callbacks/usage.py +124 -0
  82. coco_core/chat_history.py +267 -0
  83. coco_core/chat_loaders.py +26 -0
  84. coco_core/chat_sessions.py +19 -0
  85. coco_core/cross_encoders.py +18 -0
  86. coco_core/document_loaders/__init__.py +36 -0
  87. coco_core/document_loaders/base.py +155 -0
  88. coco_core/document_loaders/blob_loaders.py +38 -0
  89. coco_core/documents/__init__.py +55 -0
  90. coco_core/documents/base.py +347 -0
  91. coco_core/documents/compressor.py +74 -0
  92. coco_core/documents/transformers.py +79 -0
  93. coco_core/embeddings/__init__.py +31 -0
  94. coco_core/embeddings/embeddings.py +78 -0
  95. coco_core/embeddings/fake.py +129 -0
  96. coco_core/env.py +22 -0
  97. coco_core/example_selectors/__init__.py +47 -0
  98. coco_core/example_selectors/base.py +60 -0
  99. coco_core/example_selectors/length_based.py +131 -0
  100. coco_core/example_selectors/semantic_similarity.py +366 -0
  101. coco_core/exceptions.py +166 -0
  102. coco_core/globals.py +72 -0
  103. coco_core/indexing/__init__.py +53 -0
  104. coco_core/indexing/api.py +954 -0
  105. coco_core/indexing/base.py +661 -0
  106. coco_core/indexing/in_memory.py +104 -0
  107. coco_core/language_models/__init__.py +116 -0
  108. coco_core/language_models/_compat_bridge.py +844 -0
  109. coco_core/language_models/_utils.py +343 -0
  110. coco_core/language_models/base.py +500 -0
  111. coco_core/language_models/chat_model_stream.py +1525 -0
  112. coco_core/language_models/chat_models.py +2748 -0
  113. coco_core/language_models/fake.py +137 -0
  114. coco_core/language_models/fake_chat_models.py +396 -0
  115. coco_core/language_models/llms.py +1569 -0
  116. coco_core/language_models/model_profile.py +183 -0
  117. coco_core/load/__init__.py +44 -0
  118. coco_core/load/_validation.py +191 -0
  119. coco_core/load/dump.py +120 -0
  120. coco_core/load/load.py +828 -0
  121. coco_core/load/mapping.py +1097 -0
  122. coco_core/load/serializable.py +406 -0
  123. coco_core/messages/__init__.py +198 -0
  124. coco_core/messages/ai.py +854 -0
  125. coco_core/messages/base.py +518 -0
  126. coco_core/messages/block_translators/__init__.py +112 -0
  127. coco_core/messages/block_translators/anthropic.py +534 -0
  128. coco_core/messages/block_translators/bedrock.py +108 -0
  129. coco_core/messages/block_translators/bedrock_converse.py +329 -0
  130. coco_core/messages/block_translators/coco_v0.py +303 -0
  131. coco_core/messages/block_translators/google_genai.py +610 -0
  132. coco_core/messages/block_translators/google_vertexai.py +21 -0
  133. coco_core/messages/block_translators/groq.py +157 -0
  134. coco_core/messages/block_translators/openai.py +1089 -0
  135. coco_core/messages/chat.py +64 -0
  136. coco_core/messages/content.py +1488 -0
  137. coco_core/messages/function.py +62 -0
  138. coco_core/messages/human.py +70 -0
  139. coco_core/messages/modifier.py +33 -0
  140. coco_core/messages/system.py +70 -0
  141. coco_core/messages/tool.py +418 -0
  142. coco_core/messages/utils.py +2442 -0
  143. coco_core/output_parsers/__init__.py +101 -0
  144. coco_core/output_parsers/base.py +361 -0
  145. coco_core/output_parsers/format_instructions.py +16 -0
  146. coco_core/output_parsers/json.py +139 -0
  147. coco_core/output_parsers/list.py +249 -0
  148. coco_core/output_parsers/openai_functions.py +324 -0
  149. coco_core/output_parsers/openai_tools.py +383 -0
  150. coco_core/output_parsers/pydantic.py +141 -0
  151. coco_core/output_parsers/string.py +63 -0
  152. coco_core/output_parsers/transform.py +175 -0
  153. coco_core/output_parsers/xml.py +301 -0
  154. coco_core/outputs/__init__.py +66 -0
  155. coco_core/outputs/chat_generation.py +147 -0
  156. coco_core/outputs/chat_result.py +39 -0
  157. coco_core/outputs/generation.py +80 -0
  158. coco_core/outputs/llm_result.py +112 -0
  159. coco_core/outputs/run_info.py +22 -0
  160. coco_core/prompt_values.py +161 -0
  161. coco_core/prompts/__init__.py +101 -0
  162. coco_core/prompts/base.py +508 -0
  163. coco_core/prompts/chat.py +1495 -0
  164. coco_core/prompts/dict.py +177 -0
  165. coco_core/prompts/few_shot.py +483 -0
  166. coco_core/prompts/few_shot_with_templates.py +239 -0
  167. coco_core/prompts/image.py +177 -0
  168. coco_core/prompts/loading.py +296 -0
  169. coco_core/prompts/message.py +97 -0
  170. coco_core/prompts/prompt.py +312 -0
  171. coco_core/prompts/string.py +399 -0
  172. coco_core/prompts/structured.py +214 -0
  173. coco_core/py.typed +0 -0
  174. coco_core/rate_limiters.py +254 -0
  175. coco_core/retrievers.py +328 -0
  176. coco_core/runnables/__init__.py +136 -0
  177. coco_core/runnables/base.py +6725 -0
  178. coco_core/runnables/branch.py +457 -0
  179. coco_core/runnables/config.py +712 -0
  180. coco_core/runnables/configurable.py +710 -0
  181. coco_core/runnables/fallbacks.py +663 -0
  182. coco_core/runnables/graph.py +741 -0
  183. coco_core/runnables/graph_ascii.py +366 -0
  184. coco_core/runnables/graph_mermaid.py +503 -0
  185. coco_core/runnables/graph_png.py +215 -0
  186. coco_core/runnables/history.py +631 -0
  187. coco_core/runnables/passthrough.py +849 -0
  188. coco_core/runnables/retry.py +379 -0
  189. coco_core/runnables/router.py +239 -0
  190. coco_core/runnables/schema.py +188 -0
  191. coco_core/runnables/utils.py +798 -0
  192. coco_core/stores.py +291 -0
  193. coco_core/structured_query.py +203 -0
  194. coco_core/sys_info.py +154 -0
  195. coco_core/tools/__init__.py +95 -0
  196. coco_core/tools/base.py +1948 -0
  197. coco_core/tools/convert.py +497 -0
  198. coco_core/tools/render.py +67 -0
  199. coco_core/tools/retriever.py +94 -0
  200. coco_core/tools/simple.py +208 -0
  201. coco_core/tools/structured.py +322 -0
  202. coco_core/tracers/__init__.py +47 -0
  203. coco_core/tracers/_compat.py +95 -0
  204. coco_core/tracers/_context.py +337 -0
  205. coco_core/tracers/_streaming.py +53 -0
  206. coco_core/tracers/base.py +955 -0
  207. coco_core/tracers/coco.py +279 -0
  208. coco_core/tracers/context.py +205 -0
  209. coco_core/tracers/core.py +766 -0
  210. coco_core/tracers/event_stream.py +1101 -0
  211. coco_core/tracers/log_stream.py +765 -0
  212. coco_core/tracers/memory_stream.py +200 -0
  213. coco_core/tracers/root_listeners.py +130 -0
  214. coco_core/tracers/run_collector.py +40 -0
  215. coco_core/tracers/schemas.py +14 -0
  216. coco_core/tracers/stdout.py +205 -0
  217. coco_core/utils/__init__.py +111 -0
  218. coco_core/utils/_gateway.py +274 -0
  219. coco_core/utils/_merge.py +218 -0
  220. coco_core/utils/aiter.py +357 -0
  221. coco_core/utils/env.py +86 -0
  222. coco_core/utils/formatting.py +80 -0
  223. coco_core/utils/function_calling.py +869 -0
  224. coco_core/utils/html.py +132 -0
  225. coco_core/utils/image.py +15 -0
  226. coco_core/utils/input.py +82 -0
  227. coco_core/utils/interactive_env.py +12 -0
  228. coco_core/utils/iter.py +229 -0
  229. coco_core/utils/json.py +230 -0
  230. coco_core/utils/json_schema.py +273 -0
  231. coco_core/utils/mustache.py +706 -0
  232. coco_core/utils/pydantic.py +631 -0
  233. coco_core/utils/strings.py +69 -0
  234. coco_core/utils/usage.py +61 -0
  235. coco_core/utils/utils.py +528 -0
  236. coco_core/utils/uuid.py +57 -0
  237. coco_core/vectorstores/__init__.py +53 -0
  238. coco_core/vectorstores/base.py +1111 -0
  239. coco_core/vectorstores/in_memory.py +546 -0
  240. coco_core/vectorstores/utils.py +163 -0
  241. coco_core/version.py +3 -0
  242. coco_framework-0.1.0.dist-info/METADATA +184 -0
  243. coco_framework-0.1.0.dist-info/RECORD +400 -0
  244. coco_framework-0.1.0.dist-info/WHEEL +4 -0
  245. coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
  246. coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
  247. coco_graph/_internal/__init__.py +4 -0
  248. coco_graph/_internal/_cache.py +31 -0
  249. coco_graph/_internal/_config.py +474 -0
  250. coco_graph/_internal/_constants.py +140 -0
  251. coco_graph/_internal/_fields.py +213 -0
  252. coco_graph/_internal/_future.py +220 -0
  253. coco_graph/_internal/_pydantic.py +275 -0
  254. coco_graph/_internal/_queue.py +124 -0
  255. coco_graph/_internal/_replay.py +90 -0
  256. coco_graph/_internal/_retry.py +29 -0
  257. coco_graph/_internal/_runnable.py +969 -0
  258. coco_graph/_internal/_scratchpad.py +19 -0
  259. coco_graph/_internal/_serde.py +253 -0
  260. coco_graph/_internal/_timeout.py +25 -0
  261. coco_graph/_internal/_typing.py +54 -0
  262. coco_graph/cache/base/__init__.py +48 -0
  263. coco_graph/cache/base/py.typed +0 -0
  264. coco_graph/cache/memory/__init__.py +73 -0
  265. coco_graph/cache/redis/__init__.py +144 -0
  266. coco_graph/cache/sqlite/__init__.py +120 -0
  267. coco_graph/callbacks.py +394 -0
  268. coco_graph/channels/__init__.py +29 -0
  269. coco_graph/channels/any_value.py +72 -0
  270. coco_graph/channels/base.py +121 -0
  271. coco_graph/channels/binop.py +155 -0
  272. coco_graph/channels/delta.py +202 -0
  273. coco_graph/channels/ephemeral_value.py +79 -0
  274. coco_graph/channels/last_value.py +151 -0
  275. coco_graph/channels/named_barrier_value.py +167 -0
  276. coco_graph/channels/topic.py +94 -0
  277. coco_graph/channels/untracked_value.py +73 -0
  278. coco_graph/checkpoint/base/__init__.py +861 -0
  279. coco_graph/checkpoint/base/id.py +109 -0
  280. coco_graph/checkpoint/base/py.typed +0 -0
  281. coco_graph/checkpoint/memory/__init__.py +698 -0
  282. coco_graph/checkpoint/memory/py.typed +0 -0
  283. coco_graph/checkpoint/postgres/__init__.py +602 -0
  284. coco_graph/checkpoint/postgres/_ainternal.py +23 -0
  285. coco_graph/checkpoint/postgres/_internal.py +21 -0
  286. coco_graph/checkpoint/postgres/aio.py +691 -0
  287. coco_graph/checkpoint/postgres/base.py +666 -0
  288. coco_graph/checkpoint/postgres/py.typed +0 -0
  289. coco_graph/checkpoint/postgres/shallow.py +967 -0
  290. coco_graph/checkpoint/serde/__init__.py +0 -0
  291. coco_graph/checkpoint/serde/_msgpack.py +97 -0
  292. coco_graph/checkpoint/serde/base.py +64 -0
  293. coco_graph/checkpoint/serde/encrypted.py +80 -0
  294. coco_graph/checkpoint/serde/event_hooks.py +52 -0
  295. coco_graph/checkpoint/serde/jsonplus.py +883 -0
  296. coco_graph/checkpoint/serde/py.typed +0 -0
  297. coco_graph/checkpoint/serde/types.py +68 -0
  298. coco_graph/checkpoint/sqlite/__init__.py +645 -0
  299. coco_graph/checkpoint/sqlite/_delta.py +172 -0
  300. coco_graph/checkpoint/sqlite/aio.py +738 -0
  301. coco_graph/checkpoint/sqlite/py.typed +0 -0
  302. coco_graph/checkpoint/sqlite/utils.py +116 -0
  303. coco_graph/config.py +196 -0
  304. coco_graph/constants.py +64 -0
  305. coco_graph/errors.py +241 -0
  306. coco_graph/func/__init__.py +620 -0
  307. coco_graph/graph/__init__.py +12 -0
  308. coco_graph/graph/_branch.py +225 -0
  309. coco_graph/graph/_node.py +103 -0
  310. coco_graph/graph/message.py +437 -0
  311. coco_graph/graph/state.py +1978 -0
  312. coco_graph/graph/ui.py +227 -0
  313. coco_graph/managed/__init__.py +3 -0
  314. coco_graph/managed/base.py +31 -0
  315. coco_graph/managed/is_last_step.py +24 -0
  316. coco_graph/prebuilt/__init__.py +23 -0
  317. coco_graph/prebuilt/_tool_call_stream.py +117 -0
  318. coco_graph/prebuilt/_tool_call_transformer.py +165 -0
  319. coco_graph/prebuilt/chat_agent_executor.py +1015 -0
  320. coco_graph/prebuilt/interrupt.py +105 -0
  321. coco_graph/prebuilt/py.typed +0 -0
  322. coco_graph/prebuilt/tool_node.py +2030 -0
  323. coco_graph/prebuilt/tool_validator.py +221 -0
  324. coco_graph/pregel/__init__.py +3 -0
  325. coco_graph/pregel/_algo.py +1460 -0
  326. coco_graph/pregel/_call.py +298 -0
  327. coco_graph/pregel/_checkpoint.py +331 -0
  328. coco_graph/pregel/_config.py +0 -0
  329. coco_graph/pregel/_draw.py +294 -0
  330. coco_graph/pregel/_executor.py +223 -0
  331. coco_graph/pregel/_io.py +174 -0
  332. coco_graph/pregel/_log.py +3 -0
  333. coco_graph/pregel/_loop.py +1988 -0
  334. coco_graph/pregel/_messages.py +461 -0
  335. coco_graph/pregel/_read.py +312 -0
  336. coco_graph/pregel/_retry.py +854 -0
  337. coco_graph/pregel/_runner.py +941 -0
  338. coco_graph/pregel/_tools.py +268 -0
  339. coco_graph/pregel/_utils.py +221 -0
  340. coco_graph/pregel/_validate.py +120 -0
  341. coco_graph/pregel/_write.py +192 -0
  342. coco_graph/pregel/debug.py +302 -0
  343. coco_graph/pregel/main.py +4364 -0
  344. coco_graph/pregel/protocol.py +288 -0
  345. coco_graph/pregel/types.py +38 -0
  346. coco_graph/py.typed +0 -0
  347. coco_graph/runtime.py +350 -0
  348. coco_graph/store/base/__init__.py +1322 -0
  349. coco_graph/store/base/batch.py +371 -0
  350. coco_graph/store/base/embed.py +436 -0
  351. coco_graph/store/base/py.typed +0 -0
  352. coco_graph/store/memory/__init__.py +592 -0
  353. coco_graph/store/memory/py.typed +0 -0
  354. coco_graph/store/postgres/__init__.py +4 -0
  355. coco_graph/store/postgres/aio.py +592 -0
  356. coco_graph/store/postgres/base.py +1480 -0
  357. coco_graph/store/postgres/py.typed +0 -0
  358. coco_graph/store/sqlite/__init__.py +4 -0
  359. coco_graph/store/sqlite/aio.py +632 -0
  360. coco_graph/store/sqlite/base.py +1514 -0
  361. coco_graph/stream/__init__.py +45 -0
  362. coco_graph/stream/_convert.py +32 -0
  363. coco_graph/stream/_mux.py +523 -0
  364. coco_graph/stream/_types.py +330 -0
  365. coco_graph/stream/run_stream.py +735 -0
  366. coco_graph/stream/stream_channel.py +341 -0
  367. coco_graph/stream/transformers.py +1039 -0
  368. coco_graph/types.py +1079 -0
  369. coco_graph/typing.py +48 -0
  370. coco_graph/utils/__init__.py +1 -0
  371. coco_graph/utils/config.py +4 -0
  372. coco_graph/utils/runnable.py +3 -0
  373. coco_graph/version.py +13 -0
  374. coco_graph/warnings.py +69 -0
  375. coco_openai/__init__.py +20 -0
  376. coco_openai/_compat.py +45 -0
  377. coco_openai/_version.py +3 -0
  378. coco_openai/chat_models/__init__.py +6 -0
  379. coco_openai/chat_models/_client_utils.py +684 -0
  380. coco_openai/chat_models/_compat.py +525 -0
  381. coco_openai/chat_models/azure.py +1227 -0
  382. coco_openai/chat_models/base.py +5654 -0
  383. coco_openai/chat_models/codex.py +557 -0
  384. coco_openai/chatgpt_oauth.py +1063 -0
  385. coco_openai/data/__init__.py +1 -0
  386. coco_openai/data/_profiles.py +1655 -0
  387. coco_openai/data/profile_augmentations.toml +127 -0
  388. coco_openai/embeddings/__init__.py +6 -0
  389. coco_openai/embeddings/azure.py +240 -0
  390. coco_openai/embeddings/base.py +820 -0
  391. coco_openai/llms/__init__.py +6 -0
  392. coco_openai/llms/azure.py +249 -0
  393. coco_openai/llms/base.py +919 -0
  394. coco_openai/middleware/__init__.py +11 -0
  395. coco_openai/middleware/openai_moderation.py +484 -0
  396. coco_openai/output_parsers/__init__.py +9 -0
  397. coco_openai/output_parsers/tools.py +9 -0
  398. coco_openai/py.typed +0 -0
  399. coco_openai/tools/__init__.py +5 -0
  400. coco_openai/tools/custom_tool.py +111 -0
@@ -0,0 +1,1197 @@
1
+ """Anthropic text editor and memory tool middleware.
2
+
3
+ This module provides client-side implementations of Anthropic's text editor and
4
+ memory tools using schema-less tool definitions and tool call interception.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Annotated, Any, cast
14
+
15
+ from coco.agents.middleware.types import (
16
+ AgentMiddleware,
17
+ AgentState,
18
+ ModelRequest,
19
+ ModelResponse,
20
+ _ModelRequestOverrides,
21
+ )
22
+ from coco.tools import ToolRuntime, tool
23
+ from coco_core.messages import SystemMessage, ToolMessage
24
+ from coco_graph.types import Command
25
+ from typing_extensions import NotRequired, TypedDict
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Awaitable, Callable, Sequence
29
+
30
+
31
+ # Tool type constants
32
+ TEXT_EDITOR_TOOL_TYPE = "text_editor_20250728"
33
+ TEXT_EDITOR_TOOL_NAME = "str_replace_based_edit_tool"
34
+ MEMORY_TOOL_TYPE = "memory_20250818"
35
+ MEMORY_TOOL_NAME = "memory"
36
+
37
+ MEMORY_SYSTEM_PROMPT = """IMPORTANT: ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE \
38
+ DOING ANYTHING ELSE.
39
+ MEMORY PROTOCOL:
40
+ 1. Use the `view` command of your `memory` tool to check for earlier progress.
41
+ 2. ... (work on the task) ...
42
+ - As you make progress, record status / progress / thoughts etc in your memory.
43
+ ASSUME INTERRUPTION: Your context window might be reset at any moment, so you risk \
44
+ losing any progress that is not recorded in your memory directory."""
45
+
46
+
47
+ class FileData(TypedDict):
48
+ """Data structure for storing file contents."""
49
+
50
+ content: list[str]
51
+ """Lines of the file."""
52
+
53
+ created_at: str
54
+ """ISO 8601 timestamp of file creation."""
55
+
56
+ modified_at: str
57
+ """ISO 8601 timestamp of last modification."""
58
+
59
+
60
+ def files_reducer(
61
+ left: dict[str, FileData] | None, right: dict[str, FileData | None]
62
+ ) -> dict[str, FileData]:
63
+ """Custom reducer that merges file updates.
64
+
65
+ Args:
66
+ left: Existing files dict.
67
+ right: New files dict to merge (`None` values delete files).
68
+
69
+ Returns:
70
+ Merged `dict` where right overwrites left for matching keys.
71
+ """
72
+ if left is None:
73
+ # Filter out None values when initializing
74
+ return {k: v for k, v in right.items() if v is not None}
75
+
76
+ # Merge, filtering out None values (deletions)
77
+ result = {**left}
78
+ for k, v in right.items():
79
+ if v is None:
80
+ result.pop(k, None)
81
+ else:
82
+ result[k] = v
83
+ return result
84
+
85
+
86
+ class AnthropicToolsState(AgentState):
87
+ """State schema for Anthropic text editor and memory tools."""
88
+
89
+ text_editor_files: NotRequired[Annotated[dict[str, FileData], files_reducer]]
90
+ """Virtual file system for text editor tools."""
91
+
92
+ memory_files: NotRequired[Annotated[dict[str, FileData], files_reducer]]
93
+ """Virtual file system for memory tools."""
94
+
95
+
96
+ def _is_within_allowed_prefix(normalized: str, prefixes: Sequence[str]) -> bool:
97
+ """Check whether a normalized path lies within an allowed prefix directory.
98
+
99
+ Uses a segment-boundary comparison rather than a raw string prefix test so
100
+ that sibling directories sharing a textual prefix cannot escape the allowed
101
+ directory. For example, with prefix `/memories` the path `/memories2/evil.txt`
102
+ is rejected because it is not the prefix itself nor a descendant of it.
103
+
104
+ Args:
105
+ normalized: A normalized, forward-slash, absolute-style path.
106
+ prefixes: Allowed path prefixes to compare against.
107
+
108
+ Returns:
109
+ `True` if `normalized` exactly equals one of the prefix directories or is
110
+ contained within one of them, `False` otherwise.
111
+ """
112
+ for prefix in prefixes:
113
+ # Normalize the prefix the same way the path was normalized so the
114
+ # comparison is consistent (drop any trailing slash for the boundary).
115
+ prefix_dir = prefix.rstrip("/")
116
+ if normalized == prefix_dir or normalized.startswith(f"{prefix_dir}/"):
117
+ return True
118
+ return False
119
+
120
+
121
+ def _validate_path(path: str, *, allowed_prefixes: Sequence[str] | None = None) -> str:
122
+ """Validate and normalize file path for security.
123
+
124
+ Args:
125
+ path: The path to validate.
126
+ allowed_prefixes: Optional list of allowed path prefixes.
127
+
128
+ Returns:
129
+ Normalized canonical path.
130
+
131
+ Raises:
132
+ ValueError: If path contains traversal sequences or violates prefix rules.
133
+ """
134
+ # Reject paths with traversal attempts
135
+ if ".." in path or path.startswith("~"):
136
+ msg = f"Path traversal not allowed: {path}"
137
+ raise ValueError(msg)
138
+
139
+ # Normalize path (resolve ., //, etc.)
140
+ normalized = os.path.normpath(path)
141
+
142
+ # Convert to forward slashes for consistency
143
+ normalized = normalized.replace("\\", "/")
144
+
145
+ # Ensure path starts with /
146
+ if not normalized.startswith("/"):
147
+ normalized = f"/{normalized}"
148
+
149
+ # Check allowed prefixes if specified
150
+ if allowed_prefixes is not None and not _is_within_allowed_prefix(
151
+ normalized, allowed_prefixes
152
+ ):
153
+ msg = f"Path must start with one of {allowed_prefixes}: {path}"
154
+ raise ValueError(msg)
155
+
156
+ return normalized
157
+
158
+
159
+ def _list_directory(files: dict[str, FileData], path: str) -> list[str]:
160
+ """List files in a directory.
161
+
162
+ Args:
163
+ files: Files `dict`.
164
+ path: Normalized directory path.
165
+
166
+ Returns:
167
+ Sorted list of file paths in the directory.
168
+ """
169
+ # Ensure path ends with / for directory matching
170
+ dir_path = path if path.endswith("/") else f"{path}/"
171
+
172
+ matching_files = []
173
+ for file_path in files:
174
+ if file_path.startswith(dir_path):
175
+ # Get relative path from directory
176
+ relative = file_path[len(dir_path) :]
177
+ # Only include direct children (no subdirectories)
178
+ if "/" not in relative:
179
+ matching_files.append(file_path)
180
+
181
+ return sorted(matching_files)
182
+
183
+
184
+ class _StateClaudeFileToolMiddleware(AgentMiddleware):
185
+ """Base class for state-based file tool middleware (internal)."""
186
+
187
+ state_schema = AnthropicToolsState
188
+
189
+ def __init__(
190
+ self,
191
+ *,
192
+ tool_type: str,
193
+ tool_name: str,
194
+ state_key: str,
195
+ allowed_path_prefixes: Sequence[str] | None = None,
196
+ system_prompt: str | None = None,
197
+ ) -> None:
198
+ """Initialize.
199
+
200
+ Args:
201
+ tool_type: Tool type identifier.
202
+ tool_name: Tool name.
203
+ state_key: State key for file storage.
204
+ allowed_path_prefixes: Optional list of allowed path prefixes.
205
+ system_prompt: Optional system prompt to inject.
206
+ """
207
+ self.tool_type = tool_type
208
+ self.tool_name = tool_name
209
+ self.state_key = state_key
210
+ self.allowed_prefixes = allowed_path_prefixes
211
+ self.system_prompt = system_prompt
212
+
213
+ # Create tool that will be executed by the tool node
214
+ @tool(tool_name)
215
+ def file_tool(
216
+ runtime: ToolRuntime[None, AnthropicToolsState],
217
+ command: str,
218
+ path: str,
219
+ file_text: str | None = None,
220
+ old_str: str | None = None,
221
+ new_str: str | None = None,
222
+ insert_line: int | None = None,
223
+ new_path: str | None = None,
224
+ view_range: list[int] | None = None,
225
+ ) -> Command | str:
226
+ """Execute file operations on virtual file system.
227
+
228
+ Args:
229
+ runtime: Tool runtime providing access to state.
230
+ command: Operation to perform.
231
+ path: File path to operate on.
232
+ file_text: Full file content for create command.
233
+ old_str: String to replace for str_replace command.
234
+ new_str: Replacement string for str_replace command.
235
+ insert_line: Line number for insert command.
236
+ new_path: New path for rename command.
237
+ view_range: Line range `[start, end]` for view command.
238
+
239
+ Returns:
240
+ Command for state update or string result.
241
+ """
242
+ # Build args dict for handler methods
243
+ # `old_path` is populated for `_handle_rename`, which reads the source
244
+ # path under that key instead of `path`.
245
+ args: dict[str, Any] = {"path": path, "old_path": path}
246
+ if file_text is not None:
247
+ args["file_text"] = file_text
248
+ if old_str is not None:
249
+ args["old_str"] = old_str
250
+ if new_str is not None:
251
+ args["new_str"] = new_str
252
+ if insert_line is not None:
253
+ args["insert_line"] = insert_line
254
+ if new_path is not None:
255
+ args["new_path"] = new_path
256
+ if view_range is not None:
257
+ args["view_range"] = view_range
258
+
259
+ # Route to appropriate handler based on command
260
+ try:
261
+ if command == "view":
262
+ return self._handle_view(args, runtime.state, runtime.tool_call_id)
263
+ if command == "create":
264
+ return self._handle_create(
265
+ args, runtime.state, runtime.tool_call_id
266
+ )
267
+ if command == "str_replace":
268
+ return self._handle_str_replace(
269
+ args, runtime.state, runtime.tool_call_id
270
+ )
271
+ if command == "insert":
272
+ return self._handle_insert(
273
+ args, runtime.state, runtime.tool_call_id
274
+ )
275
+ if command == "delete":
276
+ return self._handle_delete(
277
+ args, runtime.state, runtime.tool_call_id
278
+ )
279
+ if command == "rename":
280
+ return self._handle_rename(
281
+ args, runtime.state, runtime.tool_call_id
282
+ )
283
+ return f"Unknown command: {command}"
284
+ except (ValueError, FileNotFoundError) as e:
285
+ return str(e)
286
+
287
+ self.tools = [file_tool]
288
+
289
+ def wrap_model_call(
290
+ self,
291
+ request: ModelRequest,
292
+ handler: Callable[[ModelRequest], ModelResponse],
293
+ ) -> ModelResponse:
294
+ """Inject Anthropic tool descriptor and optional system prompt."""
295
+ # Replace our BaseTool with Anthropic's native tool descriptor
296
+ tools = [
297
+ t
298
+ for t in (request.tools or [])
299
+ if getattr(t, "name", None) != self.tool_name
300
+ ] + [{"type": self.tool_type, "name": self.tool_name}]
301
+
302
+ # Inject system prompt if provided
303
+ overrides: _ModelRequestOverrides = {"tools": tools}
304
+ if self.system_prompt:
305
+ if request.system_message is not None:
306
+ new_system_content = [
307
+ *request.system_message.content_blocks,
308
+ {"type": "text", "text": f"\n\n{self.system_prompt}"},
309
+ ]
310
+ else:
311
+ new_system_content = [{"type": "text", "text": self.system_prompt}]
312
+ new_system_message = SystemMessage(
313
+ content=cast("list[str | dict[str, str]]", new_system_content)
314
+ )
315
+ overrides["system_message"] = new_system_message
316
+
317
+ return handler(request.override(**overrides))
318
+
319
+ async def awrap_model_call(
320
+ self,
321
+ request: ModelRequest,
322
+ handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
323
+ ) -> ModelResponse:
324
+ """Inject Anthropic tool descriptor and optional system prompt."""
325
+ # Replace our BaseTool with Anthropic's native tool descriptor
326
+ tools = [
327
+ t
328
+ for t in (request.tools or [])
329
+ if getattr(t, "name", None) != self.tool_name
330
+ ] + [{"type": self.tool_type, "name": self.tool_name}]
331
+
332
+ # Inject system prompt if provided
333
+ overrides: _ModelRequestOverrides = {"tools": tools}
334
+ if self.system_prompt:
335
+ if request.system_message is not None:
336
+ new_system_content = [
337
+ *request.system_message.content_blocks,
338
+ {"type": "text", "text": f"\n\n{self.system_prompt}"},
339
+ ]
340
+ else:
341
+ new_system_content = [{"type": "text", "text": self.system_prompt}]
342
+ new_system_message = SystemMessage(
343
+ content=cast("list[str | dict[str, str]]", new_system_content)
344
+ )
345
+ overrides["system_message"] = new_system_message
346
+
347
+ return await handler(request.override(**overrides))
348
+
349
+ def _handle_view(
350
+ self, args: dict, state: AnthropicToolsState, tool_call_id: str | None
351
+ ) -> Command:
352
+ """Handle view command."""
353
+ path = args["path"]
354
+ normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)
355
+
356
+ files = cast("dict[str, Any]", state.get(self.state_key, {}))
357
+ file_data = files.get(normalized_path)
358
+
359
+ if file_data is None:
360
+ # Try directory listing
361
+ matching = _list_directory(files, normalized_path)
362
+
363
+ if matching:
364
+ content = "\n".join(matching)
365
+ return Command(
366
+ update={
367
+ "messages": [
368
+ ToolMessage(
369
+ content=content,
370
+ tool_call_id=tool_call_id,
371
+ name=self.tool_name,
372
+ )
373
+ ]
374
+ }
375
+ )
376
+
377
+ msg = f"File not found: {path}"
378
+ raise FileNotFoundError(msg)
379
+
380
+ # Format file content with line numbers
381
+ lines_content = file_data["content"]
382
+ formatted_lines = [f"{i + 1}|{line}" for i, line in enumerate(lines_content)]
383
+ content = "\n".join(formatted_lines)
384
+
385
+ return Command(
386
+ update={
387
+ "messages": [
388
+ ToolMessage(
389
+ content=content,
390
+ tool_call_id=tool_call_id,
391
+ name=self.tool_name,
392
+ )
393
+ ]
394
+ }
395
+ )
396
+
397
+ def _handle_create(
398
+ self, args: dict, state: AnthropicToolsState, tool_call_id: str | None
399
+ ) -> Command:
400
+ """Handle create command."""
401
+ path = args["path"]
402
+ file_text = args["file_text"]
403
+
404
+ normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)
405
+
406
+ # Get existing files
407
+ files = cast("dict[str, Any]", state.get(self.state_key, {}))
408
+ existing = files.get(normalized_path)
409
+
410
+ # Create file data
411
+ now = datetime.now(timezone.utc).isoformat()
412
+ created_at = existing["created_at"] if existing else now
413
+
414
+ content_lines = file_text.split("\n")
415
+
416
+ return Command(
417
+ update={
418
+ self.state_key: {
419
+ normalized_path: {
420
+ "content": content_lines,
421
+ "created_at": created_at,
422
+ "modified_at": now,
423
+ }
424
+ },
425
+ "messages": [
426
+ ToolMessage(
427
+ content=f"File created: {path}",
428
+ tool_call_id=tool_call_id,
429
+ name=self.tool_name,
430
+ )
431
+ ],
432
+ }
433
+ )
434
+
435
+ def _handle_str_replace(
436
+ self, args: dict, state: AnthropicToolsState, tool_call_id: str | None
437
+ ) -> Command:
438
+ """Handle str_replace command."""
439
+ path = args["path"]
440
+ old_str = args["old_str"]
441
+ new_str = args.get("new_str", "")
442
+
443
+ normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)
444
+
445
+ # Read file
446
+ files = cast("dict[str, Any]", state.get(self.state_key, {}))
447
+ file_data = files.get(normalized_path)
448
+ if file_data is None:
449
+ msg = f"File not found: {path}"
450
+ raise FileNotFoundError(msg)
451
+
452
+ lines_content = file_data["content"]
453
+ content = "\n".join(lines_content)
454
+
455
+ # Replace string
456
+ if old_str not in content:
457
+ msg = f"String not found in file: {old_str}"
458
+ raise ValueError(msg)
459
+
460
+ new_content = content.replace(old_str, new_str, 1)
461
+ new_lines = new_content.split("\n")
462
+
463
+ # Update file
464
+ now = datetime.now(timezone.utc).isoformat()
465
+
466
+ return Command(
467
+ update={
468
+ self.state_key: {
469
+ normalized_path: {
470
+ "content": new_lines,
471
+ "created_at": file_data["created_at"],
472
+ "modified_at": now,
473
+ }
474
+ },
475
+ "messages": [
476
+ ToolMessage(
477
+ content=f"String replaced in {path}",
478
+ tool_call_id=tool_call_id,
479
+ name=self.tool_name,
480
+ )
481
+ ],
482
+ }
483
+ )
484
+
485
+ def _handle_insert(
486
+ self, args: dict, state: AnthropicToolsState, tool_call_id: str | None
487
+ ) -> Command:
488
+ """Handle insert command."""
489
+ path = args["path"]
490
+ insert_line = args["insert_line"]
491
+ text_to_insert = args["new_str"]
492
+
493
+ normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)
494
+
495
+ # Read file
496
+ files = cast("dict[str, Any]", state.get(self.state_key, {}))
497
+ file_data = files.get(normalized_path)
498
+ if file_data is None:
499
+ msg = f"File not found: {path}"
500
+ raise FileNotFoundError(msg)
501
+
502
+ lines_content = file_data["content"]
503
+ new_lines = text_to_insert.split("\n")
504
+
505
+ # Insert after insert_line (0-indexed)
506
+ updated_lines = (
507
+ lines_content[:insert_line] + new_lines + lines_content[insert_line:]
508
+ )
509
+
510
+ # Update file
511
+ now = datetime.now(timezone.utc).isoformat()
512
+
513
+ return Command(
514
+ update={
515
+ self.state_key: {
516
+ normalized_path: {
517
+ "content": updated_lines,
518
+ "created_at": file_data["created_at"],
519
+ "modified_at": now,
520
+ }
521
+ },
522
+ "messages": [
523
+ ToolMessage(
524
+ content=f"Text inserted in {path}",
525
+ tool_call_id=tool_call_id,
526
+ name=self.tool_name,
527
+ )
528
+ ],
529
+ }
530
+ )
531
+
532
+ def _handle_delete(
533
+ self,
534
+ args: dict,
535
+ state: AnthropicToolsState,
536
+ tool_call_id: str | None,
537
+ ) -> Command:
538
+ """Handle delete command."""
539
+ path = args["path"]
540
+
541
+ normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)
542
+
543
+ return Command(
544
+ update={
545
+ self.state_key: {normalized_path: None},
546
+ "messages": [
547
+ ToolMessage(
548
+ content=f"File deleted: {path}",
549
+ tool_call_id=tool_call_id,
550
+ name=self.tool_name,
551
+ )
552
+ ],
553
+ }
554
+ )
555
+
556
+ def _handle_rename(
557
+ self, args: dict, state: AnthropicToolsState, tool_call_id: str | None
558
+ ) -> Command:
559
+ """Handle rename command."""
560
+ old_path = args["old_path"]
561
+ new_path = args["new_path"]
562
+
563
+ normalized_old = _validate_path(
564
+ old_path, allowed_prefixes=self.allowed_prefixes
565
+ )
566
+ normalized_new = _validate_path(
567
+ new_path, allowed_prefixes=self.allowed_prefixes
568
+ )
569
+
570
+ # Read file
571
+ files = cast("dict[str, Any]", state.get(self.state_key, {}))
572
+ file_data = files.get(normalized_old)
573
+ if file_data is None:
574
+ msg = f"File not found: {old_path}"
575
+ raise ValueError(msg)
576
+
577
+ # Update timestamp
578
+ now = datetime.now(timezone.utc).isoformat()
579
+ file_data_copy = file_data.copy()
580
+ file_data_copy["modified_at"] = now
581
+
582
+ return Command(
583
+ update={
584
+ self.state_key: {
585
+ normalized_old: None,
586
+ normalized_new: file_data_copy,
587
+ },
588
+ "messages": [
589
+ ToolMessage(
590
+ content=f"File renamed: {old_path} -> {new_path}",
591
+ tool_call_id=tool_call_id,
592
+ name=self.tool_name,
593
+ )
594
+ ],
595
+ }
596
+ )
597
+
598
+
599
+ class StateClaudeTextEditorMiddleware(_StateClaudeFileToolMiddleware):
600
+ """State-based text editor tool middleware.
601
+
602
+ Provides Anthropic's `text_editor` tool using CocoGraph state for storage.
603
+ Files persist for the conversation thread.
604
+
605
+ Example:
606
+ ```python
607
+ from coco.agents import create_agent
608
+ from coco.agents.middleware import StateTextEditorToolMiddleware
609
+
610
+ agent = create_agent(
611
+ model=model,
612
+ tools=[],
613
+ middleware=[StateTextEditorToolMiddleware()],
614
+ )
615
+ ```
616
+ """
617
+
618
+ def __init__(
619
+ self,
620
+ *,
621
+ allowed_path_prefixes: Sequence[str] | None = None,
622
+ ) -> None:
623
+ """Initialize the text editor middleware.
624
+
625
+ Args:
626
+ allowed_path_prefixes: Optional list of allowed path prefixes.
627
+
628
+ If specified, only paths starting with these prefixes are allowed.
629
+ """
630
+ super().__init__(
631
+ tool_type=TEXT_EDITOR_TOOL_TYPE,
632
+ tool_name=TEXT_EDITOR_TOOL_NAME,
633
+ state_key="text_editor_files",
634
+ allowed_path_prefixes=allowed_path_prefixes,
635
+ )
636
+
637
+
638
+ class StateClaudeMemoryMiddleware(_StateClaudeFileToolMiddleware):
639
+ """State-based memory tool middleware.
640
+
641
+ Provides Anthropic's memory tool using CocoGraph state for storage.
642
+ Files persist for the conversation thread.
643
+
644
+ Enforces `/memories` prefix and injects Anthropic's recommended system prompt.
645
+
646
+ Example:
647
+ ```python
648
+ from coco.agents import create_agent
649
+ from coco.agents.middleware import StateMemoryToolMiddleware
650
+
651
+ agent = create_agent(
652
+ model=model,
653
+ tools=[],
654
+ middleware=[StateMemoryToolMiddleware()],
655
+ )
656
+ ```
657
+ """
658
+
659
+ def __init__(
660
+ self,
661
+ *,
662
+ allowed_path_prefixes: Sequence[str] | None = None,
663
+ system_prompt: str = MEMORY_SYSTEM_PROMPT,
664
+ ) -> None:
665
+ """Initialize the memory middleware.
666
+
667
+ Args:
668
+ allowed_path_prefixes: Optional list of allowed path prefixes.
669
+
670
+ Defaults to `['/memories']`.
671
+ system_prompt: System prompt to inject.
672
+
673
+ Defaults to Anthropic's recommended memory prompt.
674
+ """
675
+ super().__init__(
676
+ tool_type=MEMORY_TOOL_TYPE,
677
+ tool_name=MEMORY_TOOL_NAME,
678
+ state_key="memory_files",
679
+ allowed_path_prefixes=allowed_path_prefixes or ["/memories"],
680
+ system_prompt=system_prompt,
681
+ )
682
+
683
+
684
+ class _FilesystemClaudeFileToolMiddleware(AgentMiddleware):
685
+ """Base class for filesystem-based file tool middleware (internal)."""
686
+
687
+ def __init__(
688
+ self,
689
+ *,
690
+ tool_type: str,
691
+ tool_name: str,
692
+ root_path: str,
693
+ allowed_prefixes: list[str] | None = None,
694
+ max_file_size_mb: int = 10,
695
+ system_prompt: str | None = None,
696
+ ) -> None:
697
+ """Initialize.
698
+
699
+ Args:
700
+ tool_type: Tool type identifier.
701
+ tool_name: Tool name.
702
+ root_path: Root directory for file operations.
703
+ allowed_prefixes: Optional list of allowed virtual path prefixes.
704
+ max_file_size_mb: Maximum file size in MB.
705
+ system_prompt: Optional system prompt to inject.
706
+ """
707
+ self.tool_type = tool_type
708
+ self.tool_name = tool_name
709
+ self.root_path = Path(root_path).resolve()
710
+ self.allowed_prefixes = allowed_prefixes or ["/"]
711
+ self.max_file_size_bytes = max_file_size_mb * 1024 * 1024
712
+ self.system_prompt = system_prompt
713
+
714
+ # Create root directory if it doesn't exist
715
+ self.root_path.mkdir(parents=True, exist_ok=True)
716
+
717
+ # Create tool that will be executed by the tool node
718
+ @tool(tool_name)
719
+ def file_tool(
720
+ runtime: ToolRuntime,
721
+ command: str,
722
+ path: str,
723
+ file_text: str | None = None,
724
+ old_str: str | None = None,
725
+ new_str: str | None = None,
726
+ insert_line: int | None = None,
727
+ new_path: str | None = None,
728
+ view_range: list[int] | None = None,
729
+ ) -> Command | str:
730
+ """Execute file operations on filesystem.
731
+
732
+ Args:
733
+ runtime: Tool runtime providing `tool_call_id`.
734
+ command: Operation to perform.
735
+ path: File path to operate on.
736
+ file_text: Full file content for create command.
737
+ old_str: String to replace for `str_replace` command.
738
+ new_str: Replacement string for `str_replace` command.
739
+ insert_line: Line number for insert command.
740
+ new_path: New path for rename command.
741
+ view_range: Line range `[start, end]` for view command.
742
+
743
+ Returns:
744
+ Command for message update or string result.
745
+ """
746
+ # Build args dict for handler methods
747
+ # `old_path` is populated for `_handle_rename`, which reads the source
748
+ # path under that key instead of `path`.
749
+ args: dict[str, Any] = {"path": path, "old_path": path}
750
+ if file_text is not None:
751
+ args["file_text"] = file_text
752
+ if old_str is not None:
753
+ args["old_str"] = old_str
754
+ if new_str is not None:
755
+ args["new_str"] = new_str
756
+ if insert_line is not None:
757
+ args["insert_line"] = insert_line
758
+ if new_path is not None:
759
+ args["new_path"] = new_path
760
+ if view_range is not None:
761
+ args["view_range"] = view_range
762
+
763
+ # Route to appropriate handler based on command
764
+ try:
765
+ if command == "view":
766
+ return self._handle_view(args, runtime.tool_call_id)
767
+ if command == "create":
768
+ return self._handle_create(args, runtime.tool_call_id)
769
+ if command == "str_replace":
770
+ return self._handle_str_replace(args, runtime.tool_call_id)
771
+ if command == "insert":
772
+ return self._handle_insert(args, runtime.tool_call_id)
773
+ if command == "delete":
774
+ return self._handle_delete(args, runtime.tool_call_id)
775
+ if command == "rename":
776
+ return self._handle_rename(args, runtime.tool_call_id)
777
+ return f"Unknown command: {command}"
778
+ except (ValueError, FileNotFoundError, PermissionError) as e:
779
+ return str(e)
780
+
781
+ self.tools = [file_tool]
782
+
783
+ def wrap_model_call(
784
+ self,
785
+ request: ModelRequest,
786
+ handler: Callable[[ModelRequest], ModelResponse],
787
+ ) -> ModelResponse:
788
+ """Inject Anthropic tool descriptor and optional system prompt."""
789
+ # Replace our BaseTool with Anthropic's native tool descriptor
790
+ tools = [
791
+ t
792
+ for t in (request.tools or [])
793
+ if getattr(t, "name", None) != self.tool_name
794
+ ] + [{"type": self.tool_type, "name": self.tool_name}]
795
+
796
+ # Inject system prompt if provided
797
+ overrides: _ModelRequestOverrides = {"tools": tools}
798
+ if self.system_prompt:
799
+ if request.system_message is not None:
800
+ new_system_content = [
801
+ *request.system_message.content_blocks,
802
+ {"type": "text", "text": f"\n\n{self.system_prompt}"},
803
+ ]
804
+ else:
805
+ new_system_content = [{"type": "text", "text": self.system_prompt}]
806
+ new_system_message = SystemMessage(
807
+ content=cast("list[str | dict[str, str]]", new_system_content)
808
+ )
809
+ overrides["system_message"] = new_system_message
810
+
811
+ return handler(request.override(**overrides))
812
+
813
+ async def awrap_model_call(
814
+ self,
815
+ request: ModelRequest,
816
+ handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
817
+ ) -> ModelResponse:
818
+ """Inject Anthropic tool descriptor and optional system prompt."""
819
+ # Replace our BaseTool with Anthropic's native tool descriptor
820
+ tools = [
821
+ t
822
+ for t in (request.tools or [])
823
+ if getattr(t, "name", None) != self.tool_name
824
+ ] + [{"type": self.tool_type, "name": self.tool_name}]
825
+
826
+ # Inject system prompt if provided
827
+ overrides: _ModelRequestOverrides = {"tools": tools}
828
+ if self.system_prompt:
829
+ if request.system_message is not None:
830
+ new_system_content = [
831
+ *request.system_message.content_blocks,
832
+ {"type": "text", "text": f"\n\n{self.system_prompt}"},
833
+ ]
834
+ else:
835
+ new_system_content = [{"type": "text", "text": self.system_prompt}]
836
+ new_system_message = SystemMessage(
837
+ content=cast("list[str | dict[str, str]]", new_system_content)
838
+ )
839
+ overrides["system_message"] = new_system_message
840
+
841
+ return await handler(request.override(**overrides))
842
+
843
+ def _validate_and_resolve_path(self, path: str) -> Path:
844
+ """Validate and resolve a virtual path to filesystem path.
845
+
846
+ Args:
847
+ path: Virtual path (e.g., `/file.txt` or `/src/main.py`).
848
+
849
+ Returns:
850
+ Resolved absolute filesystem path within `root_path`.
851
+
852
+ Raises:
853
+ ValueError: If path contains traversal attempts, escapes root directory,
854
+ or violates `allowed_prefixes` restrictions.
855
+ """
856
+ # Normalize path
857
+ if not path.startswith("/"):
858
+ path = "/" + path
859
+
860
+ # Check for path traversal
861
+ if ".." in path or "~" in path:
862
+ msg = "Path traversal not allowed"
863
+ raise ValueError(msg)
864
+
865
+ # Convert virtual path to filesystem path
866
+ # Remove leading / and resolve relative to root
867
+ relative = path.lstrip("/")
868
+ full_path = (self.root_path / relative).resolve()
869
+
870
+ # Ensure path is within root
871
+ try:
872
+ full_path.relative_to(self.root_path)
873
+ except ValueError:
874
+ msg = f"Path outside root directory: {path}"
875
+ raise ValueError(msg) from None
876
+
877
+ # Check allowed prefixes
878
+ virtual_path = "/" + str(full_path.relative_to(self.root_path))
879
+ if self.allowed_prefixes and not _is_within_allowed_prefix(
880
+ virtual_path, self.allowed_prefixes
881
+ ):
882
+ msg = f"Path must start with one of: {self.allowed_prefixes}"
883
+ raise ValueError(msg)
884
+
885
+ return full_path
886
+
887
+ def _handle_view(self, args: dict, tool_call_id: str | None) -> Command:
888
+ """Handle view command."""
889
+ path = args["path"]
890
+ full_path = self._validate_and_resolve_path(path)
891
+
892
+ if not full_path.exists() or not full_path.is_file():
893
+ msg = f"File not found: {path}"
894
+ raise FileNotFoundError(msg)
895
+
896
+ # Check file size
897
+ if full_path.stat().st_size > self.max_file_size_bytes:
898
+ max_mb = self.max_file_size_bytes / 1024 / 1024
899
+ msg = f"File too large: {path} exceeds {max_mb}MB"
900
+ raise ValueError(msg)
901
+
902
+ # Read file
903
+ try:
904
+ content = full_path.read_text()
905
+ except UnicodeDecodeError as e:
906
+ msg = f"Cannot decode file {path}: {e}"
907
+ raise ValueError(msg) from e
908
+
909
+ # Format with line numbers
910
+ lines = content.split("\n")
911
+ # Remove trailing newline's empty string if present
912
+ if lines and lines[-1] == "":
913
+ lines = lines[:-1]
914
+ formatted_lines = [f"{i + 1}|{line}" for i, line in enumerate(lines)]
915
+ formatted_content = "\n".join(formatted_lines)
916
+
917
+ return Command(
918
+ update={
919
+ "messages": [
920
+ ToolMessage(
921
+ content=formatted_content,
922
+ tool_call_id=tool_call_id,
923
+ name=self.tool_name,
924
+ )
925
+ ]
926
+ }
927
+ )
928
+
929
+ def _handle_create(self, args: dict, tool_call_id: str | None) -> Command:
930
+ """Handle create command."""
931
+ path = args["path"]
932
+ file_text = args["file_text"]
933
+
934
+ full_path = self._validate_and_resolve_path(path)
935
+
936
+ # Create parent directories
937
+ full_path.parent.mkdir(parents=True, exist_ok=True)
938
+
939
+ # Write file
940
+ full_path.write_text(file_text + "\n")
941
+
942
+ return Command(
943
+ update={
944
+ "messages": [
945
+ ToolMessage(
946
+ content=f"File created: {path}",
947
+ tool_call_id=tool_call_id,
948
+ name=self.tool_name,
949
+ )
950
+ ]
951
+ }
952
+ )
953
+
954
+ def _handle_str_replace(self, args: dict, tool_call_id: str | None) -> Command:
955
+ """Handle `str_replace` command."""
956
+ path = args["path"]
957
+ old_str = args["old_str"]
958
+ new_str = args.get("new_str", "")
959
+
960
+ full_path = self._validate_and_resolve_path(path)
961
+
962
+ if not full_path.exists():
963
+ msg = f"File not found: {path}"
964
+ raise FileNotFoundError(msg)
965
+
966
+ # Read file
967
+ content = full_path.read_text()
968
+
969
+ # Replace string
970
+ if old_str not in content:
971
+ msg = f"String not found in file: {old_str}"
972
+ raise ValueError(msg)
973
+
974
+ new_content = content.replace(old_str, new_str, 1)
975
+
976
+ # Write back
977
+ full_path.write_text(new_content)
978
+
979
+ return Command(
980
+ update={
981
+ "messages": [
982
+ ToolMessage(
983
+ content=f"String replaced in {path}",
984
+ tool_call_id=tool_call_id,
985
+ name=self.tool_name,
986
+ )
987
+ ]
988
+ }
989
+ )
990
+
991
+ def _handle_insert(self, args: dict, tool_call_id: str | None) -> Command:
992
+ """Handle insert command."""
993
+ path = args["path"]
994
+ insert_line = args["insert_line"]
995
+ text_to_insert = args["new_str"]
996
+
997
+ full_path = self._validate_and_resolve_path(path)
998
+
999
+ if not full_path.exists():
1000
+ msg = f"File not found: {path}"
1001
+ raise FileNotFoundError(msg)
1002
+
1003
+ # Read file
1004
+ content = full_path.read_text()
1005
+ lines = content.split("\n")
1006
+ # Handle trailing newline
1007
+ if lines and lines[-1] == "":
1008
+ lines = lines[:-1]
1009
+ had_trailing_newline = True
1010
+ else:
1011
+ had_trailing_newline = False
1012
+
1013
+ new_lines = text_to_insert.split("\n")
1014
+
1015
+ # Insert after insert_line (0-indexed)
1016
+ updated_lines = lines[:insert_line] + new_lines + lines[insert_line:]
1017
+
1018
+ # Write back
1019
+ new_content = "\n".join(updated_lines)
1020
+ if had_trailing_newline:
1021
+ new_content += "\n"
1022
+ full_path.write_text(new_content)
1023
+
1024
+ return Command(
1025
+ update={
1026
+ "messages": [
1027
+ ToolMessage(
1028
+ content=f"Text inserted in {path}",
1029
+ tool_call_id=tool_call_id,
1030
+ name=self.tool_name,
1031
+ )
1032
+ ]
1033
+ }
1034
+ )
1035
+
1036
+ def _handle_delete(self, args: dict, tool_call_id: str | None) -> Command:
1037
+ """Handle delete command."""
1038
+ path = args["path"]
1039
+ full_path = self._validate_and_resolve_path(path)
1040
+
1041
+ if full_path.is_file():
1042
+ full_path.unlink()
1043
+ elif full_path.is_dir():
1044
+ shutil.rmtree(full_path)
1045
+ # If doesn't exist, silently succeed
1046
+
1047
+ return Command(
1048
+ update={
1049
+ "messages": [
1050
+ ToolMessage(
1051
+ content=f"File deleted: {path}",
1052
+ tool_call_id=tool_call_id,
1053
+ name=self.tool_name,
1054
+ )
1055
+ ]
1056
+ }
1057
+ )
1058
+
1059
+ def _handle_rename(self, args: dict, tool_call_id: str | None) -> Command:
1060
+ """Handle rename command."""
1061
+ old_path = args["old_path"]
1062
+ new_path = args["new_path"]
1063
+
1064
+ old_full = self._validate_and_resolve_path(old_path)
1065
+ new_full = self._validate_and_resolve_path(new_path)
1066
+
1067
+ if not old_full.exists():
1068
+ msg = f"File not found: {old_path}"
1069
+ raise ValueError(msg)
1070
+
1071
+ # Create parent directory for new path
1072
+ new_full.parent.mkdir(parents=True, exist_ok=True)
1073
+
1074
+ # Rename
1075
+ old_full.rename(new_full)
1076
+
1077
+ return Command(
1078
+ update={
1079
+ "messages": [
1080
+ ToolMessage(
1081
+ content=f"File renamed: {old_path} -> {new_path}",
1082
+ tool_call_id=tool_call_id,
1083
+ name=self.tool_name,
1084
+ )
1085
+ ]
1086
+ }
1087
+ )
1088
+
1089
+
1090
+ class FilesystemClaudeTextEditorMiddleware(_FilesystemClaudeFileToolMiddleware):
1091
+ """Filesystem-based text editor tool middleware.
1092
+
1093
+ Provides Anthropic's `text_editor` tool using local filesystem for storage.
1094
+ User handles persistence via volumes, git, or other mechanisms.
1095
+
1096
+ Example:
1097
+ ```python
1098
+ from coco.agents import create_agent
1099
+ from coco.agents.middleware import FilesystemTextEditorToolMiddleware
1100
+
1101
+ agent = create_agent(
1102
+ model=model,
1103
+ tools=[],
1104
+ middleware=[FilesystemTextEditorToolMiddleware(root_path="/workspace")],
1105
+ )
1106
+ ```
1107
+ """
1108
+
1109
+ def __init__(
1110
+ self,
1111
+ *,
1112
+ root_path: str,
1113
+ allowed_prefixes: list[str] | None = None,
1114
+ max_file_size_mb: int = 10,
1115
+ ) -> None:
1116
+ """Initialize the text editor middleware.
1117
+
1118
+ Args:
1119
+ root_path: Root directory for file operations.
1120
+ allowed_prefixes: Optional list of allowed virtual path prefixes.
1121
+
1122
+ Defaults to `['/']`.
1123
+ max_file_size_mb: Maximum file size in MB
1124
+
1125
+ Defaults to `10`.
1126
+ """
1127
+ super().__init__(
1128
+ tool_type=TEXT_EDITOR_TOOL_TYPE,
1129
+ tool_name=TEXT_EDITOR_TOOL_NAME,
1130
+ root_path=root_path,
1131
+ allowed_prefixes=allowed_prefixes,
1132
+ max_file_size_mb=max_file_size_mb,
1133
+ )
1134
+
1135
+
1136
+ class FilesystemClaudeMemoryMiddleware(_FilesystemClaudeFileToolMiddleware):
1137
+ """Filesystem-based memory tool middleware.
1138
+
1139
+ Provides Anthropic's memory tool using local filesystem for storage.
1140
+ User handles persistence via volumes, git, or other mechanisms.
1141
+
1142
+ Enforces `/memories` prefix and injects Anthropic's recommended system
1143
+ prompt.
1144
+
1145
+ Example:
1146
+ ```python
1147
+ from coco.agents import create_agent
1148
+ from coco.agents.middleware import FilesystemMemoryToolMiddleware
1149
+
1150
+ agent = create_agent(
1151
+ model=model,
1152
+ tools=[],
1153
+ middleware=[FilesystemMemoryToolMiddleware(root_path="/workspace")],
1154
+ )
1155
+ ```
1156
+ """
1157
+
1158
+ def __init__(
1159
+ self,
1160
+ *,
1161
+ root_path: str,
1162
+ allowed_prefixes: list[str] | None = None,
1163
+ max_file_size_mb: int = 10,
1164
+ system_prompt: str = MEMORY_SYSTEM_PROMPT,
1165
+ ) -> None:
1166
+ """Initialize the memory middleware.
1167
+
1168
+ Args:
1169
+ root_path: Root directory for file operations.
1170
+ allowed_prefixes: Optional list of allowed virtual path prefixes.
1171
+
1172
+ Defaults to `['/memories']`.
1173
+ max_file_size_mb: Maximum file size in MB
1174
+
1175
+ Defaults to `10`.
1176
+ system_prompt: System prompt to inject.
1177
+
1178
+ Defaults to Anthropic's recommended memory prompt.
1179
+ """
1180
+ super().__init__(
1181
+ tool_type=MEMORY_TOOL_TYPE,
1182
+ tool_name=MEMORY_TOOL_NAME,
1183
+ root_path=root_path,
1184
+ allowed_prefixes=allowed_prefixes or ["/memories"],
1185
+ max_file_size_mb=max_file_size_mb,
1186
+ system_prompt=system_prompt,
1187
+ )
1188
+
1189
+
1190
+ __all__ = [
1191
+ "AnthropicToolsState",
1192
+ "FileData",
1193
+ "FilesystemClaudeMemoryMiddleware",
1194
+ "FilesystemClaudeTextEditorMiddleware",
1195
+ "StateClaudeMemoryMiddleware",
1196
+ "StateClaudeTextEditorMiddleware",
1197
+ ]