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,949 @@
1
+ """Middleware that exposes a persistent shell tool to agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ import os
8
+ import queue
9
+ import signal
10
+ import subprocess
11
+ import tempfile
12
+ import threading
13
+ import time
14
+ import uuid
15
+ import weakref
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, cast, overload
19
+
20
+ from coco_core.messages import ToolMessage
21
+ from coco_core.runnables import run_in_executor
22
+ from coco_core.tools.base import ToolException
23
+ from coco_graph.channels.untracked_value import UntrackedValue
24
+ from pydantic import BaseModel, model_validator
25
+ from pydantic.json_schema import SkipJsonSchema
26
+ from typing_extensions import NotRequired, override
27
+
28
+ from coco.agents.middleware._execution import (
29
+ SHELL_TEMP_PREFIX,
30
+ BaseExecutionPolicy,
31
+ CodexSandboxExecutionPolicy,
32
+ DockerExecutionPolicy,
33
+ HostExecutionPolicy,
34
+ )
35
+ from coco.agents.middleware._redaction import (
36
+ PIIDetectionError,
37
+ PIIMatch,
38
+ RedactionRule,
39
+ ResolvedRedactionRule,
40
+ )
41
+ from coco.agents.middleware.types import (
42
+ AgentMiddleware,
43
+ AgentState,
44
+ ContextT,
45
+ PrivateStateAttr,
46
+ ResponseT,
47
+ )
48
+ from coco.tools import ToolRuntime, tool
49
+
50
+ if TYPE_CHECKING:
51
+ from collections.abc import Mapping, Sequence
52
+
53
+ from coco_graph.runtime import Runtime
54
+
55
+
56
+ LOGGER = logging.getLogger(__name__)
57
+ _DONE_MARKER_PREFIX = "__LC_SHELL_DONE__"
58
+
59
+ DEFAULT_TOOL_DESCRIPTION = (
60
+ "Execute a shell command inside a persistent session. Before running a command, "
61
+ "confirm the working directory is correct (e.g., inspect with `ls` or `pwd`) and ensure "
62
+ "any parent directories exist. Prefer absolute paths and quote paths containing spaces, "
63
+ 'such as `cd "/path/with spaces"`. Chain multiple commands with `&&` or `;` instead of '
64
+ "embedding newlines. Avoid unnecessary `cd` usage unless explicitly required so the "
65
+ "session remains stable. Outputs may be truncated when they become very large, and long "
66
+ "running commands will be terminated once their configured timeout elapses."
67
+ )
68
+ SHELL_TOOL_NAME = "shell"
69
+
70
+
71
+ def _cleanup_resources(
72
+ session: ShellSession, tempdir: tempfile.TemporaryDirectory[str] | None, timeout: float
73
+ ) -> None:
74
+ with contextlib.suppress(Exception):
75
+ session.stop(timeout)
76
+ if tempdir is not None:
77
+ with contextlib.suppress(Exception):
78
+ tempdir.cleanup()
79
+
80
+
81
+ @dataclass
82
+ class _SessionResources:
83
+ """Container for per-run shell resources."""
84
+
85
+ session: ShellSession
86
+ tempdir: tempfile.TemporaryDirectory[str] | None
87
+ policy: BaseExecutionPolicy
88
+ finalizer: weakref.finalize = field(init=False, repr=False) # type: ignore[type-arg]
89
+
90
+ def __post_init__(self) -> None:
91
+ self.finalizer = weakref.finalize(
92
+ self,
93
+ _cleanup_resources,
94
+ self.session,
95
+ self.tempdir,
96
+ self.policy.termination_timeout,
97
+ )
98
+
99
+
100
+ class ShellToolState(AgentState[ResponseT]):
101
+ """Agent state extension for tracking shell session resources.
102
+
103
+ Type Parameters:
104
+ ResponseT: The type of the structured response. Defaults to `Any`.
105
+ """
106
+
107
+ shell_session_resources: NotRequired[
108
+ Annotated[_SessionResources | None, UntrackedValue, PrivateStateAttr]
109
+ ]
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class CommandExecutionResult:
114
+ """Structured result from command execution."""
115
+
116
+ output: str
117
+ exit_code: int | None
118
+ timed_out: bool
119
+ truncated_by_lines: bool
120
+ truncated_by_bytes: bool
121
+ total_lines: int
122
+ total_bytes: int
123
+
124
+
125
+ class ShellSession:
126
+ """Persistent shell session that supports sequential command execution."""
127
+
128
+ def __init__(
129
+ self,
130
+ workspace: Path,
131
+ policy: BaseExecutionPolicy,
132
+ command: tuple[str, ...],
133
+ environment: Mapping[str, str],
134
+ ) -> None:
135
+ self._workspace = workspace
136
+ self._policy = policy
137
+ self._command = command
138
+ self._environment = dict(environment)
139
+ self._process: subprocess.Popen[str] | None = None
140
+ self._stdin: Any = None
141
+ self._queue: queue.Queue[tuple[str, str | None]] = queue.Queue()
142
+ self._lock = threading.Lock()
143
+ self._stdout_thread: threading.Thread | None = None
144
+ self._stderr_thread: threading.Thread | None = None
145
+ self._terminated = False
146
+
147
+ def start(self) -> None:
148
+ """Start the shell subprocess and reader threads.
149
+
150
+ Raises:
151
+ RuntimeError: If the shell session pipes cannot be initialized.
152
+ """
153
+ if self._process and self._process.poll() is None:
154
+ return
155
+
156
+ self._process = self._policy.spawn(
157
+ workspace=self._workspace,
158
+ env=self._environment,
159
+ command=self._command,
160
+ )
161
+ if (
162
+ self._process.stdin is None
163
+ or self._process.stdout is None
164
+ or self._process.stderr is None
165
+ ):
166
+ msg = "Failed to initialize shell session pipes."
167
+ raise RuntimeError(msg)
168
+
169
+ self._stdin = self._process.stdin
170
+ self._terminated = False
171
+ self._queue = queue.Queue()
172
+
173
+ self._stdout_thread = threading.Thread(
174
+ target=self._enqueue_stream,
175
+ args=(self._process.stdout, "stdout"),
176
+ daemon=True,
177
+ )
178
+ self._stderr_thread = threading.Thread(
179
+ target=self._enqueue_stream,
180
+ args=(self._process.stderr, "stderr"),
181
+ daemon=True,
182
+ )
183
+ self._stdout_thread.start()
184
+ self._stderr_thread.start()
185
+
186
+ def restart(self) -> None:
187
+ """Restart the shell process."""
188
+ self.stop(self._policy.termination_timeout)
189
+ self.start()
190
+
191
+ def stop(self, timeout: float) -> None:
192
+ """Stop the shell subprocess."""
193
+ if not self._process:
194
+ return
195
+
196
+ if self._process.poll() is None and not self._terminated:
197
+ try:
198
+ self._stdin.write("exit\n")
199
+ self._stdin.flush()
200
+ except (BrokenPipeError, OSError):
201
+ LOGGER.debug(
202
+ "Failed to write exit command; terminating shell session.",
203
+ exc_info=True,
204
+ )
205
+
206
+ try:
207
+ if self._process.wait(timeout=timeout) is None:
208
+ self._kill_process()
209
+ except subprocess.TimeoutExpired:
210
+ self._kill_process()
211
+ finally:
212
+ self._terminated = True
213
+ with contextlib.suppress(Exception):
214
+ self._stdin.close()
215
+ self._process = None
216
+
217
+ def execute(self, command: str, *, timeout: float) -> CommandExecutionResult:
218
+ """Execute a command in the persistent shell."""
219
+ if not self._process or self._process.poll() is not None:
220
+ msg = "Shell session is not running."
221
+ raise RuntimeError(msg)
222
+
223
+ marker = f"{_DONE_MARKER_PREFIX}{uuid.uuid4().hex}"
224
+ deadline = time.monotonic() + timeout
225
+
226
+ with self._lock:
227
+ self._drain_queue()
228
+ payload = command if command.endswith("\n") else f"{command}\n"
229
+ try:
230
+ self._stdin.write(payload)
231
+ self._stdin.write(f"printf '{marker} %s\\n' $?\n")
232
+ self._stdin.flush()
233
+ except (BrokenPipeError, OSError):
234
+ # The shell exited before we could write the marker command.
235
+ # This happens when commands like 'exit 1' terminate the shell.
236
+ return self._collect_output_after_exit(deadline)
237
+
238
+ return self._collect_output(marker, deadline, timeout)
239
+
240
+ def _collect_output(
241
+ self,
242
+ marker: str,
243
+ deadline: float,
244
+ timeout: float,
245
+ ) -> CommandExecutionResult:
246
+ collected: list[str] = []
247
+ total_lines = 0
248
+ total_bytes = 0
249
+ truncated_by_lines = False
250
+ truncated_by_bytes = False
251
+ exit_code: int | None = None
252
+ timed_out = False
253
+
254
+ while True:
255
+ remaining = deadline - time.monotonic()
256
+ if remaining <= 0:
257
+ timed_out = True
258
+ break
259
+ try:
260
+ source, data = self._queue.get(timeout=remaining)
261
+ except queue.Empty:
262
+ timed_out = True
263
+ break
264
+
265
+ if data is None:
266
+ continue
267
+
268
+ if source == "stdout" and data.startswith(marker):
269
+ _, _, status = data.partition(" ")
270
+ exit_code = self._safe_int(status.strip())
271
+ # Drain any remaining stderr that may have arrived concurrently.
272
+ # The stderr reader thread runs independently, so output might
273
+ # still be in flight when the stdout marker arrives.
274
+ self._drain_remaining_stderr(collected, deadline)
275
+ break
276
+
277
+ total_lines += 1
278
+ encoded = data.encode("utf-8", "replace")
279
+ total_bytes += len(encoded)
280
+
281
+ if total_lines > self._policy.max_output_lines:
282
+ truncated_by_lines = True
283
+ continue
284
+
285
+ if (
286
+ self._policy.max_output_bytes is not None
287
+ and total_bytes > self._policy.max_output_bytes
288
+ ):
289
+ truncated_by_bytes = True
290
+ continue
291
+
292
+ if source == "stderr":
293
+ stripped = data.rstrip("\n")
294
+ collected.append(f"[stderr] {stripped}")
295
+ if data.endswith("\n"):
296
+ collected.append("\n")
297
+ else:
298
+ collected.append(data)
299
+
300
+ if timed_out:
301
+ LOGGER.warning(
302
+ "Command timed out after %.2f seconds; restarting shell session.",
303
+ timeout,
304
+ )
305
+ self.restart()
306
+ return CommandExecutionResult(
307
+ output="",
308
+ exit_code=None,
309
+ timed_out=True,
310
+ truncated_by_lines=truncated_by_lines,
311
+ truncated_by_bytes=truncated_by_bytes,
312
+ total_lines=total_lines,
313
+ total_bytes=total_bytes,
314
+ )
315
+
316
+ output = "".join(collected)
317
+ return CommandExecutionResult(
318
+ output=output,
319
+ exit_code=exit_code,
320
+ timed_out=False,
321
+ truncated_by_lines=truncated_by_lines,
322
+ truncated_by_bytes=truncated_by_bytes,
323
+ total_lines=total_lines,
324
+ total_bytes=total_bytes,
325
+ )
326
+
327
+ def _collect_output_after_exit(self, deadline: float) -> CommandExecutionResult:
328
+ """Collect output after the shell exited unexpectedly.
329
+
330
+ Called when a `BrokenPipeError` occurs while writing to stdin, indicating the
331
+ shell process terminated (e.g., due to an 'exit' command).
332
+
333
+ Args:
334
+ deadline: Absolute time by which collection must complete.
335
+
336
+ Returns:
337
+ `CommandExecutionResult` with collected output and the process exit code.
338
+ """
339
+ collected: list[str] = []
340
+ total_lines = 0
341
+ total_bytes = 0
342
+ truncated_by_lines = False
343
+ truncated_by_bytes = False
344
+
345
+ # Give reader threads a brief moment to enqueue any remaining output.
346
+ drain_timeout = 0.1
347
+ drain_deadline = min(time.monotonic() + drain_timeout, deadline)
348
+
349
+ while True:
350
+ remaining = drain_deadline - time.monotonic()
351
+ if remaining <= 0:
352
+ break
353
+ try:
354
+ source, data = self._queue.get(timeout=remaining)
355
+ except queue.Empty:
356
+ break
357
+
358
+ if data is None:
359
+ # EOF marker from a reader thread; continue draining.
360
+ continue
361
+
362
+ total_lines += 1
363
+ encoded = data.encode("utf-8", "replace")
364
+ total_bytes += len(encoded)
365
+
366
+ if total_lines > self._policy.max_output_lines:
367
+ truncated_by_lines = True
368
+ continue
369
+
370
+ if (
371
+ self._policy.max_output_bytes is not None
372
+ and total_bytes > self._policy.max_output_bytes
373
+ ):
374
+ truncated_by_bytes = True
375
+ continue
376
+
377
+ if source == "stderr":
378
+ stripped = data.rstrip("\n")
379
+ collected.append(f"[stderr] {stripped}")
380
+ if data.endswith("\n"):
381
+ collected.append("\n")
382
+ else:
383
+ collected.append(data)
384
+
385
+ # Get exit code from the terminated process.
386
+ exit_code: int | None = None
387
+ if self._process:
388
+ exit_code = self._process.poll()
389
+
390
+ output = "".join(collected)
391
+ return CommandExecutionResult(
392
+ output=output,
393
+ exit_code=exit_code,
394
+ timed_out=False,
395
+ truncated_by_lines=truncated_by_lines,
396
+ truncated_by_bytes=truncated_by_bytes,
397
+ total_lines=total_lines,
398
+ total_bytes=total_bytes,
399
+ )
400
+
401
+ def _kill_process(self) -> None:
402
+ if not self._process:
403
+ return
404
+
405
+ if hasattr(os, "killpg"):
406
+ try:
407
+ child_pgid = os.getpgid(self._process.pid)
408
+ # Only send a group kill when the child has a dedicated process group.
409
+ # If the child shares our group, killpg would terminate the caller too,
410
+ # so fall through to the direct kill below. That direct kill reaps only
411
+ # the immediate child, so any descendants it spawned may be orphaned.
412
+ # This applies to HostExecutionPolicy(create_process_group=False), the
413
+ # only policy that runs the shell in the caller's process group.
414
+ if child_pgid != os.getpgrp():
415
+ os.killpg(child_pgid, signal.SIGKILL)
416
+ return
417
+ except ProcessLookupError:
418
+ # Process already gone; nothing left to kill.
419
+ return
420
+ except OSError:
421
+ # e.g. EPERM while querying or signaling the group. Don't leak the
422
+ # child silently; fall through to a direct kill.
423
+ LOGGER.warning(
424
+ "Group kill failed; falling back to direct kill.",
425
+ exc_info=True,
426
+ )
427
+
428
+ try:
429
+ self._process.kill()
430
+ except ProcessLookupError:
431
+ # Process exited between the check above and this kill; nothing to do.
432
+ pass
433
+ except OSError:
434
+ # The fallback kill can hit the same condition (e.g. EPERM) that routed us
435
+ # here. Log rather than let it escape the session shutdown path.
436
+ LOGGER.warning(
437
+ "Direct kill failed.",
438
+ exc_info=True,
439
+ )
440
+
441
+ def _enqueue_stream(self, stream: Any, label: str) -> None:
442
+ for line in iter(stream.readline, ""):
443
+ self._queue.put((label, line))
444
+ self._queue.put((label, None))
445
+
446
+ def _drain_queue(self) -> None:
447
+ while True:
448
+ try:
449
+ self._queue.get_nowait()
450
+ except queue.Empty:
451
+ break
452
+
453
+ def _drain_remaining_stderr(
454
+ self, collected: list[str], deadline: float, drain_timeout: float = 0.05
455
+ ) -> None:
456
+ """Drain any stderr output that arrived concurrently with the done marker.
457
+
458
+ The stdout and stderr reader threads run independently. When a command writes to
459
+ stderr just before exiting, the stderr output may still be in transit when the
460
+ done marker arrives on stdout. This method briefly polls the queue to capture
461
+ such output.
462
+
463
+ Args:
464
+ collected: The list to append collected stderr lines to.
465
+ deadline: The original command deadline (used as an upper bound).
466
+ drain_timeout: Maximum time to wait for additional stderr output.
467
+ """
468
+ drain_deadline = min(time.monotonic() + drain_timeout, deadline)
469
+ while True:
470
+ remaining = drain_deadline - time.monotonic()
471
+ if remaining <= 0:
472
+ break
473
+ try:
474
+ source, data = self._queue.get(timeout=remaining)
475
+ except queue.Empty:
476
+ break
477
+ if data is None or source != "stderr":
478
+ continue
479
+ stripped = data.rstrip("\n")
480
+ collected.append(f"[stderr] {stripped}")
481
+ if data.endswith("\n"):
482
+ collected.append("\n")
483
+
484
+ @staticmethod
485
+ def _safe_int(value: str) -> int | None:
486
+ with contextlib.suppress(ValueError):
487
+ return int(value)
488
+ return None
489
+
490
+
491
+ class _ShellToolInput(BaseModel):
492
+ """Input schema for the persistent shell tool."""
493
+
494
+ command: str | None = None
495
+ """The shell command to execute."""
496
+
497
+ restart: bool | None = None
498
+ """Whether to restart the shell session."""
499
+
500
+ runtime: Annotated[Any, SkipJsonSchema()] = None
501
+ """The runtime for the shell tool.
502
+
503
+ Included as a workaround at the moment bc args_schema doesn't work with
504
+ injected ToolRuntime.
505
+ """
506
+
507
+ @model_validator(mode="after")
508
+ def validate_payload(self) -> _ShellToolInput:
509
+ if self.command is None and not self.restart:
510
+ msg = "Shell tool requires either 'command' or 'restart'."
511
+ raise ValueError(msg)
512
+ if self.command is not None and self.restart:
513
+ msg = "Specify only one of 'command' or 'restart'."
514
+ raise ValueError(msg)
515
+ return self
516
+
517
+
518
+ class ShellToolMiddleware(AgentMiddleware[ShellToolState[ResponseT], ContextT, ResponseT]):
519
+ """Middleware that registers a persistent shell tool for agents.
520
+
521
+ The middleware exposes a single long-lived shell session. Use the execution policy
522
+ to match your deployment's security posture:
523
+
524
+ * `HostExecutionPolicy` – full host access; best for trusted environments where the
525
+ agent already runs inside a container or VM that provides isolation.
526
+ * `CodexSandboxExecutionPolicy` – reuses the Codex CLI sandbox for additional
527
+ syscall/filesystem restrictions when the CLI is available.
528
+ * `DockerExecutionPolicy` – launches a separate Docker container for each agent run,
529
+ providing harder isolation, optional read-only root filesystems, and user
530
+ remapping.
531
+
532
+ When no policy is provided the middleware defaults to `HostExecutionPolicy`.
533
+ """
534
+
535
+ state_schema = ShellToolState # type: ignore[assignment]
536
+
537
+ def __init__(
538
+ self,
539
+ workspace_root: str | Path | None = None,
540
+ *,
541
+ startup_commands: tuple[str, ...] | list[str] | str | None = None,
542
+ shutdown_commands: tuple[str, ...] | list[str] | str | None = None,
543
+ execution_policy: BaseExecutionPolicy | None = None,
544
+ redaction_rules: tuple[RedactionRule, ...] | list[RedactionRule] | None = None,
545
+ tool_description: str | None = None,
546
+ tool_name: str = SHELL_TOOL_NAME,
547
+ shell_command: Sequence[str] | str | None = None,
548
+ env: Mapping[str, Any] | None = None,
549
+ ) -> None:
550
+ """Initialize an instance of `ShellToolMiddleware`.
551
+
552
+ Args:
553
+ workspace_root: Base directory for the shell session.
554
+
555
+ If omitted, a temporary directory is created when the agent starts and
556
+ removed when it ends.
557
+ startup_commands: Optional commands executed sequentially after the session
558
+ starts.
559
+ shutdown_commands: Optional commands executed before the session shuts down.
560
+ execution_policy: Execution policy controlling timeouts, output limits, and
561
+ resource configuration.
562
+
563
+ Defaults to `HostExecutionPolicy` for native execution.
564
+ redaction_rules: Optional redaction rules to sanitize command output before
565
+ returning it to the model.
566
+
567
+ !!! warning
568
+ Redaction rules are applied post execution and do not prevent
569
+ exfiltration of secrets or sensitive data when using
570
+ `HostExecutionPolicy`.
571
+
572
+ tool_description: Optional override for the registered shell tool
573
+ description.
574
+ tool_name: Name for the registered shell tool.
575
+
576
+ Defaults to `"shell"`.
577
+ shell_command: Optional shell executable (string) or argument sequence used
578
+ to launch the persistent session.
579
+
580
+ Defaults to an implementation-defined bash command.
581
+ env: Optional environment variables to supply to the shell session.
582
+
583
+ Values are coerced to strings before command execution. If omitted, the
584
+ session inherits the parent process environment.
585
+ """
586
+ super().__init__()
587
+ self._workspace_root = Path(workspace_root) if workspace_root else None
588
+ self._tool_name = tool_name
589
+ self._shell_command = self._normalize_shell_command(shell_command)
590
+ self._environment = self._normalize_env(env)
591
+ if execution_policy is not None:
592
+ self._execution_policy = execution_policy
593
+ else:
594
+ self._execution_policy = HostExecutionPolicy()
595
+ rules = redaction_rules or ()
596
+ self._redaction_rules: tuple[ResolvedRedactionRule, ...] = tuple(
597
+ rule.resolve() for rule in rules
598
+ )
599
+ self._startup_commands = self._normalize_commands(startup_commands)
600
+ self._shutdown_commands = self._normalize_commands(shutdown_commands)
601
+
602
+ # Create a proper tool that executes directly (no interception needed)
603
+ description = tool_description or DEFAULT_TOOL_DESCRIPTION
604
+
605
+ @tool(self._tool_name, args_schema=_ShellToolInput, description=description)
606
+ def shell_tool(
607
+ *,
608
+ runtime: ToolRuntime[None, ShellToolState],
609
+ command: str | None = None,
610
+ restart: bool = False,
611
+ ) -> ToolMessage | str:
612
+ resources = self._get_or_create_resources(runtime.state)
613
+ return self._run_shell_tool(
614
+ resources,
615
+ {"command": command, "restart": restart},
616
+ tool_call_id=runtime.tool_call_id,
617
+ )
618
+
619
+ self._shell_tool = shell_tool
620
+ self.tools = [self._shell_tool]
621
+
622
+ @staticmethod
623
+ def _normalize_commands(
624
+ commands: tuple[str, ...] | list[str] | str | None,
625
+ ) -> tuple[str, ...]:
626
+ if commands is None:
627
+ return ()
628
+ if isinstance(commands, str):
629
+ return (commands,)
630
+ return tuple(commands)
631
+
632
+ @staticmethod
633
+ def _normalize_shell_command(
634
+ shell_command: Sequence[str] | str | None,
635
+ ) -> tuple[str, ...]:
636
+ if shell_command is None:
637
+ return ("/bin/bash",)
638
+ normalized = (shell_command,) if isinstance(shell_command, str) else tuple(shell_command)
639
+ if not normalized:
640
+ msg = "Shell command must contain at least one argument."
641
+ raise ValueError(msg)
642
+ return normalized
643
+
644
+ @staticmethod
645
+ def _normalize_env(env: Mapping[str, Any] | None) -> dict[str, str] | None:
646
+ if env is None:
647
+ return None
648
+ normalized: dict[str, str] = {}
649
+ for key, value in env.items():
650
+ if not isinstance(key, str):
651
+ msg = "Environment variable names must be strings." # type: ignore[unreachable]
652
+ raise TypeError(msg)
653
+ normalized[key] = str(value)
654
+ return normalized
655
+
656
+ @override
657
+ def before_agent(
658
+ self, state: ShellToolState[ResponseT], runtime: Runtime[ContextT]
659
+ ) -> dict[str, Any] | None:
660
+ """Start the shell session and run startup commands.
661
+
662
+ Args:
663
+ state: The current agent state.
664
+ runtime: The runtime context.
665
+
666
+ Returns:
667
+ Shell session resources to be stored in the agent state.
668
+ """
669
+ resources = self._get_or_create_resources(state)
670
+ return {"shell_session_resources": resources}
671
+
672
+ async def abefore_agent(
673
+ self, state: ShellToolState[ResponseT], runtime: Runtime[ContextT]
674
+ ) -> dict[str, Any] | None:
675
+ """Async start the shell session and run startup commands.
676
+
677
+ Args:
678
+ state: The current agent state.
679
+ runtime: The runtime context.
680
+
681
+ Returns:
682
+ Shell session resources to be stored in the agent state.
683
+ """
684
+ return await run_in_executor(None, self.before_agent, state, runtime)
685
+
686
+ @override
687
+ def after_agent(self, state: ShellToolState[ResponseT], runtime: Runtime[ContextT]) -> None:
688
+ """Run shutdown commands and release resources when an agent completes."""
689
+ resources = state.get("shell_session_resources")
690
+ if not isinstance(resources, _SessionResources):
691
+ # Resources were never created, nothing to clean up
692
+ return
693
+ try:
694
+ self._run_shutdown_commands(resources.session)
695
+ finally:
696
+ resources.finalizer()
697
+
698
+ async def aafter_agent(
699
+ self, state: ShellToolState[ResponseT], runtime: Runtime[ContextT]
700
+ ) -> None:
701
+ """Async run shutdown commands and release resources when an agent completes."""
702
+ return self.after_agent(state, runtime)
703
+
704
+ def _get_or_create_resources(self, state: ShellToolState[ResponseT]) -> _SessionResources:
705
+ """Get existing resources from state or create new ones if they don't exist.
706
+
707
+ This method enables resumability by checking if resources already exist in the state
708
+ (e.g., after an interrupt), and only creating new resources if they're not present.
709
+
710
+ Args:
711
+ state: The agent state which may contain shell session resources.
712
+
713
+ Returns:
714
+ Session resources, either retrieved from state or newly created.
715
+ """
716
+ resources = state.get("shell_session_resources")
717
+ if isinstance(resources, _SessionResources):
718
+ return resources
719
+
720
+ new_resources = self._create_resources()
721
+ # Cast needed to make state dict-like for mutation
722
+ cast("dict[str, Any]", state)["shell_session_resources"] = new_resources
723
+ return new_resources
724
+
725
+ def _create_resources(self) -> _SessionResources:
726
+ workspace = self._workspace_root
727
+ tempdir: tempfile.TemporaryDirectory[str] | None = None
728
+ if workspace is None:
729
+ tempdir = tempfile.TemporaryDirectory(prefix=SHELL_TEMP_PREFIX)
730
+ workspace_path = Path(tempdir.name)
731
+ else:
732
+ workspace_path = workspace
733
+ workspace_path.mkdir(parents=True, exist_ok=True)
734
+
735
+ session = ShellSession(
736
+ workspace_path,
737
+ self._execution_policy,
738
+ self._shell_command,
739
+ self._environment or {},
740
+ )
741
+ try:
742
+ session.start()
743
+ LOGGER.info("Started shell session in %s", workspace_path)
744
+ self._run_startup_commands(session)
745
+ except BaseException:
746
+ LOGGER.exception("Starting shell session failed; cleaning up resources.")
747
+ session.stop(self._execution_policy.termination_timeout)
748
+ if tempdir is not None:
749
+ tempdir.cleanup()
750
+ raise
751
+
752
+ return _SessionResources(session=session, tempdir=tempdir, policy=self._execution_policy)
753
+
754
+ def _run_startup_commands(self, session: ShellSession) -> None:
755
+ if not self._startup_commands:
756
+ return
757
+ for command in self._startup_commands:
758
+ result = session.execute(command, timeout=self._execution_policy.startup_timeout)
759
+ if result.timed_out or (result.exit_code not in {0, None}):
760
+ msg = f"Startup command '{command}' failed with exit code {result.exit_code}"
761
+ raise RuntimeError(msg)
762
+
763
+ def _run_shutdown_commands(self, session: ShellSession) -> None:
764
+ if not self._shutdown_commands:
765
+ return
766
+ for command in self._shutdown_commands:
767
+ try:
768
+ result = session.execute(command, timeout=self._execution_policy.command_timeout)
769
+ if result.timed_out:
770
+ LOGGER.warning("Shutdown command '%s' timed out.", command)
771
+ elif result.exit_code not in {0, None}:
772
+ LOGGER.warning(
773
+ "Shutdown command '%s' exited with %s.", command, result.exit_code
774
+ )
775
+ except (RuntimeError, ToolException, OSError) as exc:
776
+ LOGGER.warning(
777
+ "Failed to run shutdown command '%s': %s", command, exc, exc_info=True
778
+ )
779
+
780
+ def _apply_redactions(self, content: str) -> tuple[str, dict[str, list[PIIMatch]]]:
781
+ """Apply configured redaction rules to command output."""
782
+ matches_by_type: dict[str, list[PIIMatch]] = {}
783
+ updated = content
784
+ for rule in self._redaction_rules:
785
+ updated, matches = rule.apply(updated)
786
+ if matches:
787
+ matches_by_type.setdefault(rule.pii_type, []).extend(matches)
788
+ return updated, matches_by_type
789
+
790
+ @overload
791
+ def _run_shell_tool(
792
+ self,
793
+ resources: _SessionResources,
794
+ payload: dict[str, Any],
795
+ *,
796
+ tool_call_id: str,
797
+ ) -> ToolMessage: ...
798
+
799
+ @overload
800
+ def _run_shell_tool(
801
+ self,
802
+ resources: _SessionResources,
803
+ payload: dict[str, Any],
804
+ *,
805
+ tool_call_id: None,
806
+ ) -> str: ...
807
+
808
+ def _run_shell_tool(
809
+ self,
810
+ resources: _SessionResources,
811
+ payload: dict[str, Any],
812
+ *,
813
+ tool_call_id: str | None,
814
+ ) -> ToolMessage | str:
815
+ session = resources.session
816
+
817
+ if payload.get("restart"):
818
+ LOGGER.info("Restarting shell session on request.")
819
+ try:
820
+ session.restart()
821
+ self._run_startup_commands(session)
822
+ except BaseException as err:
823
+ LOGGER.exception("Restarting shell session failed; session remains unavailable.")
824
+ msg = "Failed to restart shell session."
825
+ raise ToolException(msg) from err
826
+ message = "Shell session restarted."
827
+ return self._format_tool_message(message, tool_call_id, status="success")
828
+
829
+ command = payload.get("command")
830
+ if not command or not isinstance(command, str):
831
+ msg = "Shell tool expects a 'command' string when restart is not requested."
832
+ raise ToolException(msg)
833
+
834
+ LOGGER.info("Executing shell command: %s", command)
835
+ result = session.execute(command, timeout=self._execution_policy.command_timeout)
836
+
837
+ if result.timed_out:
838
+ timeout_seconds = self._execution_policy.command_timeout
839
+ message = f"Error: Command timed out after {timeout_seconds:.1f} seconds."
840
+ return self._format_tool_message(
841
+ message,
842
+ tool_call_id,
843
+ status="error",
844
+ artifact={
845
+ "timed_out": True,
846
+ "exit_code": None,
847
+ },
848
+ )
849
+
850
+ try:
851
+ sanitized_output, matches = self._apply_redactions(result.output)
852
+ except PIIDetectionError as error:
853
+ LOGGER.warning("Blocking command output due to detected %s.", error.pii_type)
854
+ message = f"Output blocked: detected {error.pii_type}."
855
+ return self._format_tool_message(
856
+ message,
857
+ tool_call_id,
858
+ status="error",
859
+ artifact={
860
+ "timed_out": False,
861
+ "exit_code": result.exit_code,
862
+ "matches": {error.pii_type: error.matches},
863
+ },
864
+ )
865
+
866
+ sanitized_output = sanitized_output or "<no output>"
867
+ if result.truncated_by_lines:
868
+ sanitized_output = (
869
+ f"{sanitized_output.rstrip()}\n\n"
870
+ f"... Output truncated at {self._execution_policy.max_output_lines} lines "
871
+ f"(observed {result.total_lines})."
872
+ )
873
+ if result.truncated_by_bytes and self._execution_policy.max_output_bytes is not None:
874
+ sanitized_output = (
875
+ f"{sanitized_output.rstrip()}\n\n"
876
+ f"... Output truncated at {self._execution_policy.max_output_bytes} bytes "
877
+ f"(observed {result.total_bytes})."
878
+ )
879
+
880
+ if result.exit_code not in {0, None}:
881
+ sanitized_output = f"{sanitized_output.rstrip()}\n\nExit code: {result.exit_code}"
882
+ final_status: Literal["success", "error"] = "error"
883
+ else:
884
+ final_status = "success"
885
+
886
+ artifact = {
887
+ "timed_out": False,
888
+ "exit_code": result.exit_code,
889
+ "truncated_by_lines": result.truncated_by_lines,
890
+ "truncated_by_bytes": result.truncated_by_bytes,
891
+ "total_lines": result.total_lines,
892
+ "total_bytes": result.total_bytes,
893
+ "redaction_matches": matches,
894
+ }
895
+
896
+ return self._format_tool_message(
897
+ sanitized_output,
898
+ tool_call_id,
899
+ status=final_status,
900
+ artifact=artifact,
901
+ )
902
+
903
+ @overload
904
+ def _format_tool_message(
905
+ self,
906
+ content: str,
907
+ tool_call_id: str,
908
+ *,
909
+ status: Literal["success", "error"],
910
+ artifact: dict[str, Any] | None = None,
911
+ ) -> ToolMessage: ...
912
+
913
+ @overload
914
+ def _format_tool_message(
915
+ self,
916
+ content: str,
917
+ tool_call_id: None,
918
+ *,
919
+ status: Literal["success", "error"],
920
+ artifact: dict[str, Any] | None = None,
921
+ ) -> str: ...
922
+
923
+ def _format_tool_message(
924
+ self,
925
+ content: str,
926
+ tool_call_id: str | None,
927
+ *,
928
+ status: Literal["success", "error"],
929
+ artifact: dict[str, Any] | None = None,
930
+ ) -> ToolMessage | str:
931
+ artifact = artifact or {}
932
+ if tool_call_id is None:
933
+ return content
934
+ return ToolMessage(
935
+ content=content,
936
+ tool_call_id=tool_call_id,
937
+ name=self._tool_name,
938
+ status=status,
939
+ artifact=artifact,
940
+ )
941
+
942
+
943
+ __all__ = [
944
+ "CodexSandboxExecutionPolicy",
945
+ "DockerExecutionPolicy",
946
+ "HostExecutionPolicy",
947
+ "RedactionRule",
948
+ "ShellToolMiddleware",
949
+ ]