docagent-cli 0.0.35__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 (300) hide show
  1. docagent_cli/__init__.py +36 -0
  2. docagent_cli/__main__.py +6 -0
  3. docagent_cli/_ask_user_types.py +90 -0
  4. docagent_cli/_cli_context.py +27 -0
  5. docagent_cli/_debug.py +52 -0
  6. docagent_cli/_env_vars.py +56 -0
  7. docagent_cli/_server_config.py +352 -0
  8. docagent_cli/_session_stats.py +114 -0
  9. docagent_cli/_testing_models.py +144 -0
  10. docagent_cli/_version.py +17 -0
  11. docagent_cli/agent.py +1193 -0
  12. docagent_cli/app.py +4979 -0
  13. docagent_cli/app.tcss +283 -0
  14. docagent_cli/ask_user.py +301 -0
  15. docagent_cli/built_in_skills/__init__.py +5 -0
  16. docagent_cli/built_in_skills/doc-coauthoring/SKILL.md +375 -0
  17. docagent_cli/built_in_skills/docx/LICENSE.txt +30 -0
  18. docagent_cli/built_in_skills/docx/SKILL.md +590 -0
  19. docagent_cli/built_in_skills/docx/scripts/__init__.py +1 -0
  20. docagent_cli/built_in_skills/docx/scripts/accept_changes.py +135 -0
  21. docagent_cli/built_in_skills/docx/scripts/comment.py +318 -0
  22. docagent_cli/built_in_skills/docx/scripts/office/helpers/__init__.py +0 -0
  23. docagent_cli/built_in_skills/docx/scripts/office/helpers/merge_runs.py +199 -0
  24. docagent_cli/built_in_skills/docx/scripts/office/helpers/simplify_redlines.py +197 -0
  25. docagent_cli/built_in_skills/docx/scripts/office/pack.py +159 -0
  26. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  27. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  28. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  29. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  30. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  31. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  32. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  33. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  34. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  35. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  36. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  37. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  38. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  39. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  40. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  41. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  42. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  43. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  44. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  45. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  46. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  47. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  48. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  49. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  50. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  51. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  52. docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  53. docagent_cli/built_in_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
  54. docagent_cli/built_in_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
  55. docagent_cli/built_in_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
  56. docagent_cli/built_in_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
  57. docagent_cli/built_in_skills/docx/scripts/office/schemas/mce/mc.xsd +75 -0
  58. docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  59. docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  60. docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  61. docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  62. docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  63. docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  64. docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  65. docagent_cli/built_in_skills/docx/scripts/office/soffice.py +183 -0
  66. docagent_cli/built_in_skills/docx/scripts/office/unpack.py +132 -0
  67. docagent_cli/built_in_skills/docx/scripts/office/validate.py +111 -0
  68. docagent_cli/built_in_skills/docx/scripts/office/validators/__init__.py +15 -0
  69. docagent_cli/built_in_skills/docx/scripts/office/validators/base.py +847 -0
  70. docagent_cli/built_in_skills/docx/scripts/office/validators/docx.py +446 -0
  71. docagent_cli/built_in_skills/docx/scripts/office/validators/pptx.py +275 -0
  72. docagent_cli/built_in_skills/docx/scripts/office/validators/redlining.py +247 -0
  73. docagent_cli/built_in_skills/docx/scripts/templates/comments.xml +3 -0
  74. docagent_cli/built_in_skills/docx/scripts/templates/commentsExtended.xml +3 -0
  75. docagent_cli/built_in_skills/docx/scripts/templates/commentsExtensible.xml +3 -0
  76. docagent_cli/built_in_skills/docx/scripts/templates/commentsIds.xml +3 -0
  77. docagent_cli/built_in_skills/docx/scripts/templates/people.xml +3 -0
  78. docagent_cli/built_in_skills/pdf/LICENSE.txt +30 -0
  79. docagent_cli/built_in_skills/pdf/SKILL.md +314 -0
  80. docagent_cli/built_in_skills/pdf/forms.md +294 -0
  81. docagent_cli/built_in_skills/pdf/reference.md +612 -0
  82. docagent_cli/built_in_skills/pdf/scripts/check_bounding_boxes.py +65 -0
  83. docagent_cli/built_in_skills/pdf/scripts/check_fillable_fields.py +11 -0
  84. docagent_cli/built_in_skills/pdf/scripts/convert_pdf_to_images.py +33 -0
  85. docagent_cli/built_in_skills/pdf/scripts/create_validation_image.py +37 -0
  86. docagent_cli/built_in_skills/pdf/scripts/extract_form_field_info.py +122 -0
  87. docagent_cli/built_in_skills/pdf/scripts/extract_form_structure.py +115 -0
  88. docagent_cli/built_in_skills/pdf/scripts/fill_fillable_fields.py +98 -0
  89. docagent_cli/built_in_skills/pdf/scripts/fill_pdf_form_with_annotations.py +107 -0
  90. docagent_cli/built_in_skills/pptx/LICENSE.txt +30 -0
  91. docagent_cli/built_in_skills/pptx/SKILL.md +232 -0
  92. docagent_cli/built_in_skills/pptx/editing.md +205 -0
  93. docagent_cli/built_in_skills/pptx/pptxgenjs.md +420 -0
  94. docagent_cli/built_in_skills/pptx/scripts/__init__.py +0 -0
  95. docagent_cli/built_in_skills/pptx/scripts/add_slide.py +195 -0
  96. docagent_cli/built_in_skills/pptx/scripts/clean.py +286 -0
  97. docagent_cli/built_in_skills/pptx/scripts/office/helpers/__init__.py +0 -0
  98. docagent_cli/built_in_skills/pptx/scripts/office/helpers/merge_runs.py +199 -0
  99. docagent_cli/built_in_skills/pptx/scripts/office/helpers/simplify_redlines.py +197 -0
  100. docagent_cli/built_in_skills/pptx/scripts/office/pack.py +159 -0
  101. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  102. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  103. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  104. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  105. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  106. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  107. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  108. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  109. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  110. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  111. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  112. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  113. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  114. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  115. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  116. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  117. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  118. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  119. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  120. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  121. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  122. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  123. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  124. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  125. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  126. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  127. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  128. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
  129. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
  130. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
  131. docagent_cli/built_in_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
  132. docagent_cli/built_in_skills/pptx/scripts/office/schemas/mce/mc.xsd +75 -0
  133. docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  134. docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  135. docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  136. docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  137. docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  138. docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  139. docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  140. docagent_cli/built_in_skills/pptx/scripts/office/soffice.py +183 -0
  141. docagent_cli/built_in_skills/pptx/scripts/office/unpack.py +132 -0
  142. docagent_cli/built_in_skills/pptx/scripts/office/validate.py +111 -0
  143. docagent_cli/built_in_skills/pptx/scripts/office/validators/__init__.py +15 -0
  144. docagent_cli/built_in_skills/pptx/scripts/office/validators/base.py +847 -0
  145. docagent_cli/built_in_skills/pptx/scripts/office/validators/docx.py +446 -0
  146. docagent_cli/built_in_skills/pptx/scripts/office/validators/pptx.py +275 -0
  147. docagent_cli/built_in_skills/pptx/scripts/office/validators/redlining.py +247 -0
  148. docagent_cli/built_in_skills/pptx/scripts/thumbnail.py +289 -0
  149. docagent_cli/built_in_skills/remember/SKILL.md +118 -0
  150. docagent_cli/built_in_skills/skill-creator/LICENSE.txt +202 -0
  151. docagent_cli/built_in_skills/skill-creator/SKILL.md +485 -0
  152. docagent_cli/built_in_skills/skill-creator/agents/analyzer.md +274 -0
  153. docagent_cli/built_in_skills/skill-creator/agents/comparator.md +202 -0
  154. docagent_cli/built_in_skills/skill-creator/agents/grader.md +223 -0
  155. docagent_cli/built_in_skills/skill-creator/assets/eval_review.html +146 -0
  156. docagent_cli/built_in_skills/skill-creator/eval-viewer/generate_review.py +471 -0
  157. docagent_cli/built_in_skills/skill-creator/eval-viewer/viewer.html +1325 -0
  158. docagent_cli/built_in_skills/skill-creator/references/schemas.md +430 -0
  159. docagent_cli/built_in_skills/skill-creator/scripts/__init__.py +0 -0
  160. docagent_cli/built_in_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  161. docagent_cli/built_in_skills/skill-creator/scripts/generate_report.py +326 -0
  162. docagent_cli/built_in_skills/skill-creator/scripts/improve_description.py +247 -0
  163. docagent_cli/built_in_skills/skill-creator/scripts/package_skill.py +136 -0
  164. docagent_cli/built_in_skills/skill-creator/scripts/quick_validate.py +103 -0
  165. docagent_cli/built_in_skills/skill-creator/scripts/run_eval.py +310 -0
  166. docagent_cli/built_in_skills/skill-creator/scripts/run_loop.py +328 -0
  167. docagent_cli/built_in_skills/skill-creator/scripts/utils.py +47 -0
  168. docagent_cli/built_in_skills/theme-factory/LICENSE.txt +202 -0
  169. docagent_cli/built_in_skills/theme-factory/SKILL.md +59 -0
  170. docagent_cli/built_in_skills/theme-factory/theme-showcase.pdf +0 -0
  171. docagent_cli/built_in_skills/theme-factory/themes/arctic-frost.md +19 -0
  172. docagent_cli/built_in_skills/theme-factory/themes/botanical-garden.md +19 -0
  173. docagent_cli/built_in_skills/theme-factory/themes/desert-rose.md +19 -0
  174. docagent_cli/built_in_skills/theme-factory/themes/forest-canopy.md +19 -0
  175. docagent_cli/built_in_skills/theme-factory/themes/golden-hour.md +19 -0
  176. docagent_cli/built_in_skills/theme-factory/themes/midnight-galaxy.md +19 -0
  177. docagent_cli/built_in_skills/theme-factory/themes/modern-minimalist.md +19 -0
  178. docagent_cli/built_in_skills/theme-factory/themes/ocean-depths.md +19 -0
  179. docagent_cli/built_in_skills/theme-factory/themes/sunset-boulevard.md +19 -0
  180. docagent_cli/built_in_skills/theme-factory/themes/tech-innovation.md +19 -0
  181. docagent_cli/built_in_skills/xlsx/LICENSE.txt +30 -0
  182. docagent_cli/built_in_skills/xlsx/SKILL.md +292 -0
  183. docagent_cli/built_in_skills/xlsx/scripts/office/helpers/__init__.py +0 -0
  184. docagent_cli/built_in_skills/xlsx/scripts/office/helpers/merge_runs.py +199 -0
  185. docagent_cli/built_in_skills/xlsx/scripts/office/helpers/simplify_redlines.py +197 -0
  186. docagent_cli/built_in_skills/xlsx/scripts/office/pack.py +159 -0
  187. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  188. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  189. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  190. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  191. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  192. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  193. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  194. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  195. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  196. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  197. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  198. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  199. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  200. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  201. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  202. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  203. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  204. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  205. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  206. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  207. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  208. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  209. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  210. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  211. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  212. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  213. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  214. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
  215. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
  216. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
  217. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
  218. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/mce/mc.xsd +75 -0
  219. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  220. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  221. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  222. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  223. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  224. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  225. docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  226. docagent_cli/built_in_skills/xlsx/scripts/office/soffice.py +183 -0
  227. docagent_cli/built_in_skills/xlsx/scripts/office/unpack.py +132 -0
  228. docagent_cli/built_in_skills/xlsx/scripts/office/validate.py +111 -0
  229. docagent_cli/built_in_skills/xlsx/scripts/office/validators/__init__.py +15 -0
  230. docagent_cli/built_in_skills/xlsx/scripts/office/validators/base.py +847 -0
  231. docagent_cli/built_in_skills/xlsx/scripts/office/validators/docx.py +446 -0
  232. docagent_cli/built_in_skills/xlsx/scripts/office/validators/pptx.py +275 -0
  233. docagent_cli/built_in_skills/xlsx/scripts/office/validators/redlining.py +247 -0
  234. docagent_cli/built_in_skills/xlsx/scripts/recalc.py +184 -0
  235. docagent_cli/clipboard.py +128 -0
  236. docagent_cli/command_registry.py +284 -0
  237. docagent_cli/config.py +2418 -0
  238. docagent_cli/configurable_model.py +162 -0
  239. docagent_cli/default_agent_prompt.md +12 -0
  240. docagent_cli/editor.py +142 -0
  241. docagent_cli/file_ops.py +473 -0
  242. docagent_cli/formatting.py +28 -0
  243. docagent_cli/hooks.py +206 -0
  244. docagent_cli/input.py +787 -0
  245. docagent_cli/integrations/__init__.py +1 -0
  246. docagent_cli/integrations/sandbox_factory.py +873 -0
  247. docagent_cli/integrations/sandbox_provider.py +71 -0
  248. docagent_cli/local_context.py +718 -0
  249. docagent_cli/main.py +1641 -0
  250. docagent_cli/mcp_tools.py +707 -0
  251. docagent_cli/mcp_trust.py +168 -0
  252. docagent_cli/media_utils.py +478 -0
  253. docagent_cli/model_config.py +1620 -0
  254. docagent_cli/non_interactive.py +948 -0
  255. docagent_cli/offload.py +371 -0
  256. docagent_cli/output.py +69 -0
  257. docagent_cli/project_utils.py +188 -0
  258. docagent_cli/py.typed +0 -0
  259. docagent_cli/remote_client.py +515 -0
  260. docagent_cli/server.py +520 -0
  261. docagent_cli/server_graph.py +196 -0
  262. docagent_cli/server_manager.py +365 -0
  263. docagent_cli/sessions.py +1262 -0
  264. docagent_cli/skills/__init__.py +18 -0
  265. docagent_cli/skills/commands.py +1090 -0
  266. docagent_cli/skills/load.py +192 -0
  267. docagent_cli/subagents.py +173 -0
  268. docagent_cli/system_prompt.md +247 -0
  269. docagent_cli/textual_adapter.py +1352 -0
  270. docagent_cli/theme.py +842 -0
  271. docagent_cli/token_state.py +31 -0
  272. docagent_cli/tool_display.py +298 -0
  273. docagent_cli/tools.py +236 -0
  274. docagent_cli/ui.py +420 -0
  275. docagent_cli/unicode_security.py +516 -0
  276. docagent_cli/update_check.py +454 -0
  277. docagent_cli/widgets/__init__.py +9 -0
  278. docagent_cli/widgets/_links.py +63 -0
  279. docagent_cli/widgets/approval.py +442 -0
  280. docagent_cli/widgets/ask_user.py +398 -0
  281. docagent_cli/widgets/autocomplete.py +691 -0
  282. docagent_cli/widgets/chat_input.py +1827 -0
  283. docagent_cli/widgets/diff.py +248 -0
  284. docagent_cli/widgets/history.py +188 -0
  285. docagent_cli/widgets/loading.py +177 -0
  286. docagent_cli/widgets/mcp_viewer.py +362 -0
  287. docagent_cli/widgets/message_store.py +675 -0
  288. docagent_cli/widgets/messages.py +1751 -0
  289. docagent_cli/widgets/model_selector.py +964 -0
  290. docagent_cli/widgets/status.py +372 -0
  291. docagent_cli/widgets/theme_selector.py +164 -0
  292. docagent_cli/widgets/thread_selector.py +1905 -0
  293. docagent_cli/widgets/tool_renderers.py +148 -0
  294. docagent_cli/widgets/tool_widgets.py +274 -0
  295. docagent_cli/widgets/welcome.py +339 -0
  296. docagent_cli-0.0.35.data/data/docagent_cli/default_agent_prompt.md +12 -0
  297. docagent_cli-0.0.35.dist-info/METADATA +200 -0
  298. docagent_cli-0.0.35.dist-info/RECORD +300 -0
  299. docagent_cli-0.0.35.dist-info/WHEEL +4 -0
  300. docagent_cli-0.0.35.dist-info/entry_points.txt +3 -0
docagent_cli/config.py ADDED
@@ -0,0 +1,2418 @@
1
+ """Configuration, constants, and model creation for the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import shlex
11
+ import sys
12
+ import threading
13
+ from dataclasses import dataclass
14
+ from enum import StrEnum
15
+ from importlib.metadata import PackageNotFoundError, distribution
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any
18
+ from urllib.parse import unquote, urlparse
19
+
20
+ from docagent_cli._version import __version__
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Lazy bootstrap: dotenv loading, LANGSMITH_PROJECT override, and start-path
26
+ # detection are deferred until first access of `settings` (via module
27
+ # `__getattr__`). This avoids disk I/O and path traversal during import for
28
+ # callers that never touch `settings` (e.g. `docagent --help`).
29
+ # ---------------------------------------------------------------------------
30
+
31
+ _bootstrap_done = False
32
+ """Whether `_ensure_bootstrap()` has executed."""
33
+
34
+ _bootstrap_lock = threading.Lock()
35
+ """Guards `_ensure_bootstrap()` against concurrent access from the main
36
+ thread and the prewarm worker thread."""
37
+
38
+ _singleton_lock = threading.Lock()
39
+ """Guards lazy singleton construction in `_get_console` / `_get_settings`."""
40
+
41
+ _bootstrap_start_path: Path | None = None
42
+ """Working directory captured at bootstrap time for dotenv and project discovery."""
43
+
44
+ _original_langsmith_project: str | None = None
45
+ """Caller's `LANGSMITH_PROJECT` value before the CLI overrides it for agent traces.
46
+
47
+ Captured inside `_ensure_bootstrap()` after dotenv loading but before the
48
+ `LANGSMITH_PROJECT` override, so `.env`-only values are visible.
49
+ """
50
+
51
+
52
+ def _find_dotenv_from_start_path(start_path: Path) -> Path | None:
53
+ """Find the nearest `.env` file from an explicit start path upward.
54
+
55
+ Args:
56
+ start_path: Directory to start searching from.
57
+
58
+ Returns:
59
+ Path to the nearest `.env` file, or `None` if not found.
60
+ """
61
+ current = start_path.expanduser().resolve()
62
+ for parent in [current, *list(current.parents)]:
63
+ candidate = parent / ".env"
64
+ try:
65
+ if candidate.is_file():
66
+ return candidate
67
+ except OSError:
68
+ logger.warning("Could not inspect .env candidate %s", candidate)
69
+ continue
70
+ return None
71
+
72
+
73
+ # Global user-level .env (~/.docagent/.env); sentinel when Path.home() fails.
74
+ try:
75
+ _GLOBAL_DOTENV_PATH = Path.home() / ".docagent" / ".env"
76
+ except RuntimeError:
77
+ _GLOBAL_DOTENV_PATH = Path("/nonexistent/.docagent/.env")
78
+
79
+
80
+ def _load_dotenv(*, start_path: Path | None = None) -> bool:
81
+ """Load environment variables from project and global `.env` files.
82
+
83
+ Loads in order (first write wins, `override=False`):
84
+
85
+ 1. Project/CWD `.env` — project-specific values
86
+ 2. `~/.docagent/.env` — global user defaults
87
+
88
+ Both layers use `override=False` (the python-dotenv default) so that
89
+ shell-exported variables always take precedence over dotenv files.
90
+ Because project loads first, the effective precedence is:
91
+
92
+ ```text
93
+ shell env (incl. inline `VAR=x`) > project `.env` > global `.env`
94
+ ```
95
+
96
+ !!! note
97
+
98
+ To scope credentials to the CLI without colliding with
99
+ identically-named shell exports, use the `DEEPAGENTS_CLI_` env-var
100
+ prefix (see `resolve_env_var` in `docagent_cli.model_config`).
101
+
102
+ Args:
103
+ start_path: Directory to use for project `.env` discovery.
104
+
105
+ Returns:
106
+ `True` when at least one dotenv file was loaded, `False` otherwise.
107
+ """
108
+ import dotenv
109
+
110
+ loaded = False
111
+
112
+ # 1. Project/CWD .env — loads first so project values are set before the
113
+ # global file, which can only fill in vars not already present.
114
+ dotenv_path: Path | str | None = None
115
+ try:
116
+ if start_path is None:
117
+ loaded = dotenv.load_dotenv(override=False) or loaded
118
+ else:
119
+ dotenv_path = _find_dotenv_from_start_path(start_path)
120
+ if dotenv_path is not None:
121
+ loaded = (
122
+ dotenv.load_dotenv(dotenv_path=dotenv_path, override=False)
123
+ or loaded
124
+ )
125
+ except (OSError, ValueError):
126
+ logger.warning(
127
+ "Could not read project dotenv at %s; project env vars will not be loaded",
128
+ dotenv_path or start_path or "cwd",
129
+ exc_info=True,
130
+ )
131
+
132
+ # 2. Global (~/.docagent/.env) — fills in any vars not already set by
133
+ # the shell or the project dotenv.
134
+ # try/except wraps both is_file() and load_dotenv() to cover the TOCTOU
135
+ # window where the file can vanish between stat and open.
136
+ try:
137
+ if _GLOBAL_DOTENV_PATH.is_file() and dotenv.load_dotenv(
138
+ dotenv_path=_GLOBAL_DOTENV_PATH, override=False
139
+ ):
140
+ loaded = True
141
+ logger.debug("Loaded global dotenv: %s", _GLOBAL_DOTENV_PATH)
142
+ except (OSError, ValueError):
143
+ logger.warning(
144
+ "Could not read global dotenv at %s; global defaults will not be applied",
145
+ _GLOBAL_DOTENV_PATH,
146
+ exc_info=True,
147
+ )
148
+
149
+ return loaded
150
+
151
+
152
+ def _ensure_bootstrap() -> None:
153
+ """Run one-time bootstrap: dotenv loading and `LANGSMITH_PROJECT` override.
154
+
155
+ Idempotent and thread-safe — subsequent calls are no-ops. Called
156
+ automatically by `_get_settings()` when `settings` is first accessed.
157
+
158
+ The flag is set in `finally` so that partial failures (e.g. a
159
+ malformed `.env`) still mark bootstrap as done — preventing infinite retry
160
+ loops. Exceptions are caught and logged at ERROR level; the CLI proceeds
161
+ with the environment as-is.
162
+ """
163
+ global _bootstrap_done, _bootstrap_start_path, _original_langsmith_project # noqa: PLW0603
164
+
165
+ if _bootstrap_done:
166
+ return
167
+
168
+ with _bootstrap_lock:
169
+ if _bootstrap_done: # double-check after acquiring lock
170
+ return
171
+
172
+ try:
173
+ from docagent_cli.project_utils import (
174
+ get_server_project_context as _get_server_project_context,
175
+ )
176
+
177
+ ctx = _get_server_project_context()
178
+ _bootstrap_start_path = ctx.user_cwd if ctx else None
179
+ _load_dotenv(start_path=_bootstrap_start_path)
180
+
181
+ # Capture AFTER dotenv loading so .env-only values are visible,
182
+ # but BEFORE the override below replaces it.
183
+ _original_langsmith_project = os.environ.get("LANGSMITH_PROJECT")
184
+
185
+ # CRITICAL: Override LANGSMITH_PROJECT to route agent traces to a
186
+ # separate project. LangSmith reads LANGSMITH_PROJECT at invocation
187
+ # time, so we override it here and preserve the user's original
188
+ # value for shell commands.
189
+ from docagent_cli._env_vars import LANGSMITH_PROJECT
190
+
191
+ docagent_project = os.environ.get(LANGSMITH_PROJECT)
192
+ if docagent_project:
193
+ os.environ["LANGSMITH_PROJECT"] = docagent_project
194
+
195
+ # Propagate prefixed LangSmith env vars to canonical names.
196
+ # The CLI resolves prefixed vars via resolve_env_var(), but the
197
+ # LangSmith SDK reads os.environ directly and has no knowledge
198
+ # of the DEEPAGENTS_CLI_ prefix. Setting canonical vars here
199
+ # bridges that gap.
200
+ from docagent_cli.model_config import _ENV_PREFIX
201
+
202
+ for canonical in (
203
+ "LANGSMITH_API_KEY",
204
+ "LANGCHAIN_API_KEY",
205
+ "LANGSMITH_TRACING",
206
+ "LANGCHAIN_TRACING_V2",
207
+ ):
208
+ prefixed = f"{_ENV_PREFIX}{canonical}"
209
+ if prefixed not in os.environ:
210
+ continue
211
+ prefixed_val = os.environ[prefixed]
212
+ if canonical not in os.environ:
213
+ # Propagate (including empty string for explicit disable).
214
+ os.environ[canonical] = prefixed_val
215
+ elif os.environ[canonical] != prefixed_val:
216
+ logger.warning(
217
+ "Both %s and %s are set with different values; "
218
+ "the LangSmith SDK will use %s while the CLI "
219
+ "prefers %s. Unset one to avoid confusion.",
220
+ canonical,
221
+ prefixed,
222
+ canonical,
223
+ prefixed,
224
+ )
225
+ except Exception:
226
+ logger.exception(
227
+ "Bootstrap failed; .env values and LANGSMITH_PROJECT override "
228
+ "may be missing. The CLI will proceed with environment as-is.",
229
+ )
230
+ finally:
231
+ _bootstrap_done = True
232
+
233
+
234
+ if TYPE_CHECKING:
235
+ from langchain_core.language_models import BaseChatModel
236
+ from langchain_core.runnables import RunnableConfig
237
+ from rich.console import Console
238
+
239
+ # Static type stubs for lazy module attributes resolved by __getattr__.
240
+ # At runtime these are created on first access by _get_settings() /
241
+ # _get_console() and cached in globals().
242
+ settings: Settings
243
+ console: Console
244
+
245
+ MODE_PREFIXES: dict[str, str] = {
246
+ "shell": "!",
247
+ "command": "/",
248
+ }
249
+ """Maps each non-normal mode to its trigger character."""
250
+
251
+ MODE_DISPLAY_GLYPHS: dict[str, str] = {
252
+ "shell": "$",
253
+ "command": "/",
254
+ }
255
+ """Maps each non-normal mode to its display glyph shown in the prompt/UI."""
256
+
257
+ if MODE_PREFIXES.keys() != MODE_DISPLAY_GLYPHS.keys():
258
+ _only_prefixes = MODE_PREFIXES.keys() - MODE_DISPLAY_GLYPHS.keys()
259
+ _only_glyphs = MODE_DISPLAY_GLYPHS.keys() - MODE_PREFIXES.keys()
260
+ msg = (
261
+ "MODE_PREFIXES and MODE_DISPLAY_GLYPHS have mismatched keys: "
262
+ f"only in PREFIXES={_only_prefixes}, only in GLYPHS={_only_glyphs}"
263
+ )
264
+ raise ValueError(msg)
265
+
266
+ PREFIX_TO_MODE: dict[str, str] = {v: k for k, v in MODE_PREFIXES.items()}
267
+ """Reverse lookup: trigger character -> mode name."""
268
+
269
+
270
+ class CharsetMode(StrEnum):
271
+ """Character set mode for TUI display."""
272
+
273
+ UNICODE = "unicode"
274
+ """Always use Unicode glyphs (e.g. `⏺`, `✓`, `…`)."""
275
+
276
+ ASCII = "ascii"
277
+ """Always use ASCII-safe fallbacks (e.g. `(*)`, `[OK]`, `...`)."""
278
+
279
+ AUTO = "auto"
280
+ """Detect charset support at runtime and pick Unicode or ASCII."""
281
+
282
+
283
+ @dataclass(frozen=True)
284
+ class Glyphs:
285
+ """Character glyphs for TUI display."""
286
+
287
+ tool_prefix: str # ⏺ vs (*)
288
+ ellipsis: str # … vs ...
289
+ checkmark: str # ✓ vs [OK]
290
+ error: str # ✗ vs [X]
291
+ circle_empty: str # ○ vs [ ]
292
+ circle_filled: str # ● vs [*]
293
+ output_prefix: str # ⎿ vs L
294
+ spinner_frames: tuple[str, ...] # Braille vs ASCII spinner
295
+ pause: str # ⏸ vs ||
296
+ newline: str # ⏎ vs \\n
297
+ warning: str # ⚠ vs [!]
298
+ question: str # ? vs [?]
299
+ arrow_up: str # up arrow vs ^
300
+ arrow_down: str # down arrow vs v
301
+ bullet: str # bullet vs -
302
+ cursor: str # cursor vs >
303
+
304
+ # Box-drawing characters
305
+ box_vertical: str # │ vs |
306
+ box_horizontal: str # ─ vs -
307
+ box_double_horizontal: str # ═ vs =
308
+
309
+ # Diff-specific
310
+ gutter_bar: str # ▌ vs |
311
+
312
+ # Status bar
313
+ git_branch: str # "↗" vs "git:"
314
+
315
+
316
+ UNICODE_GLYPHS = Glyphs(
317
+ tool_prefix="⏺",
318
+ ellipsis="…",
319
+ checkmark="✓",
320
+ error="✗",
321
+ circle_empty="○",
322
+ circle_filled="●",
323
+ output_prefix="⎿",
324
+ spinner_frames=("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"),
325
+ pause="⏸",
326
+ newline="⏎",
327
+ warning="⚠",
328
+ question="?",
329
+ arrow_up="↑",
330
+ arrow_down="↓",
331
+ bullet="•",
332
+ cursor="›", # noqa: RUF001 # Intentional Unicode glyph
333
+ # Box-drawing characters
334
+ box_vertical="│",
335
+ box_horizontal="─",
336
+ box_double_horizontal="═",
337
+ gutter_bar="▌",
338
+ git_branch="↗",
339
+ )
340
+ """Glyph set for terminals with full Unicode support."""
341
+
342
+ ASCII_GLYPHS = Glyphs(
343
+ tool_prefix="(*)",
344
+ ellipsis="...",
345
+ checkmark="[OK]",
346
+ error="[X]",
347
+ circle_empty="[ ]",
348
+ circle_filled="[*]",
349
+ output_prefix="L",
350
+ spinner_frames=("(-)", "(\\)", "(|)", "(/)"),
351
+ pause="||",
352
+ newline="\\n",
353
+ warning="[!]",
354
+ question="[?]",
355
+ arrow_up="^",
356
+ arrow_down="v",
357
+ bullet="-",
358
+ cursor=">",
359
+ # Box-drawing characters
360
+ box_vertical="|",
361
+ box_horizontal="-",
362
+ box_double_horizontal="=",
363
+ gutter_bar="|",
364
+ git_branch="git:",
365
+ )
366
+ """Glyph set for terminals limited to 7-bit ASCII."""
367
+
368
+ _glyphs_cache: Glyphs | None = None
369
+ """Module-level cache for detected glyphs."""
370
+
371
+ _editable_cache: tuple[bool, str | None] | None = None
372
+ """Module-level cache for editable install info: (is_editable, source_path)."""
373
+
374
+ _langsmith_url_cache: tuple[str, str] | None = None
375
+ """Module-level cache for successful LangSmith project URL lookups."""
376
+
377
+ _LANGSMITH_URL_LOOKUP_TIMEOUT_SECONDS = 2.0
378
+ """Max seconds to wait for LangSmith project URL lookup.
379
+
380
+ Kept short so tracing metadata can never stall CLI flows.
381
+ """
382
+
383
+
384
+ def _resolve_editable_info() -> tuple[bool, str | None]:
385
+ """Parse PEP 610 `direct_url.json` once and cache both results.
386
+
387
+ Returns:
388
+ Tuple of (is_editable, contracted_source_path). The path is
389
+ `~`-contracted when it falls under the user's home directory, or
390
+ `None` when the install is non-editable or the path is unavailable.
391
+ """
392
+ global _editable_cache # noqa: PLW0603 # Module-level cache requires global statement
393
+ if _editable_cache is not None:
394
+ return _editable_cache
395
+
396
+ editable = False
397
+ path: str | None = None
398
+
399
+ try:
400
+ dist = distribution("docagent-cli")
401
+ raw = dist.read_text("direct_url.json")
402
+ if raw:
403
+ data = json.loads(raw)
404
+ editable = data.get("dir_info", {}).get("editable", False)
405
+ if editable:
406
+ url = data.get("url", "")
407
+ if url.startswith("file://"):
408
+ path = unquote(urlparse(url).path)
409
+ home = str(Path.home())
410
+ if path.startswith(home):
411
+ path = "~" + path[len(home) :]
412
+ except (PackageNotFoundError, FileNotFoundError, json.JSONDecodeError, TypeError):
413
+ logger.debug(
414
+ "Failed to read editable install info from PEP 610 metadata",
415
+ exc_info=True,
416
+ )
417
+
418
+ _editable_cache = (editable, path)
419
+ return _editable_cache
420
+
421
+
422
+ def _is_editable_install() -> bool:
423
+ """Check if docagent-cli is installed in editable mode.
424
+
425
+ Uses PEP 610 `direct_url.json` metadata to detect editable installs.
426
+
427
+ Returns:
428
+ `True` if installed in editable mode, `False` otherwise.
429
+ """
430
+ return _resolve_editable_info()[0]
431
+
432
+
433
+ def _get_editable_install_path() -> str | None:
434
+ """Return the `~`-contracted source directory for an editable install.
435
+
436
+ Returns `None` for non-editable installs or when the path cannot be
437
+ determined.
438
+ """
439
+ return _resolve_editable_info()[1]
440
+
441
+
442
+ def _detect_charset_mode() -> CharsetMode:
443
+ """Auto-detect terminal charset capabilities.
444
+
445
+ Returns:
446
+ The detected CharsetMode based on environment and terminal encoding.
447
+ """
448
+ env_mode = os.environ.get("UI_CHARSET_MODE", "auto").lower()
449
+ if env_mode == "unicode":
450
+ return CharsetMode.UNICODE
451
+ if env_mode == "ascii":
452
+ return CharsetMode.ASCII
453
+
454
+ # Auto: check stdout encoding and LANG
455
+ encoding = getattr(sys.stdout, "encoding", "") or ""
456
+ if "utf" in encoding.lower():
457
+ return CharsetMode.UNICODE
458
+ lang = os.environ.get("LANG", "") or os.environ.get("LC_ALL", "")
459
+ if "utf" in lang.lower():
460
+ return CharsetMode.UNICODE
461
+ return CharsetMode.ASCII
462
+
463
+
464
+ def get_glyphs() -> Glyphs:
465
+ """Get the glyph set for the current charset mode.
466
+
467
+ Returns:
468
+ The appropriate Glyphs instance based on charset mode detection.
469
+ """
470
+ global _glyphs_cache # noqa: PLW0603 # Module-level cache requires global statement
471
+ if _glyphs_cache is not None:
472
+ return _glyphs_cache
473
+
474
+ mode = _detect_charset_mode()
475
+ _glyphs_cache = ASCII_GLYPHS if mode == CharsetMode.ASCII else UNICODE_GLYPHS
476
+ return _glyphs_cache
477
+
478
+
479
+ def reset_glyphs_cache() -> None:
480
+ """Reset the glyphs cache (for testing)."""
481
+ global _glyphs_cache # noqa: PLW0603 # Module-level cache requires global statement
482
+ _glyphs_cache = None
483
+
484
+
485
+ def is_ascii_mode() -> bool:
486
+ """Check whether the terminal is in ASCII charset mode.
487
+
488
+ Convenience wrapper so widgets can branch on charset without importing
489
+ both `_detect_charset_mode` and `CharsetMode`.
490
+
491
+ Returns:
492
+ `True` when the detected charset mode is ASCII.
493
+ """
494
+ return _detect_charset_mode() == CharsetMode.ASCII
495
+
496
+
497
+ def newline_shortcut() -> str:
498
+ """Return the platform-native label for the newline keyboard shortcut.
499
+
500
+ macOS labels the modifier "Option" while other platforms use Ctrl+J
501
+ as the most reliable cross-terminal shortcut.
502
+
503
+ Returns:
504
+ A human-readable shortcut string, e.g. `'Option+Enter'` or `'Ctrl+J'`.
505
+ """
506
+ return "Option+Enter" if sys.platform == "darwin" else "Ctrl+J"
507
+
508
+
509
+ _UNICODE_BANNER = f"""
510
+ ██████╗ ███████╗ ███████╗ ██████╗ ▄▓▓▄
511
+ ██╔══██╗ ██╔════╝ ██╔════╝ ██╔══██╗ ▓•███▙
512
+ ██║ ██║ █████╗ █████╗ ██████╔╝ ░▀▀████▙▖
513
+ ██║ ██║ ██╔══╝ ██╔══╝ ██╔═══╝ █▓████▙▖
514
+ ██████╔╝ ███████╗ ███████╗ ██║ ▝█▓█████▙
515
+ ╚═════╝ ╚══════╝ ╚══════╝ ╚═╝ ░▜█▓████▙
516
+ ░█▀█▛▀▀▜▙▄
517
+ ░▀░▀▒▛░░ ▝▀▘
518
+
519
+ █████╗ ██████╗ ███████╗ ███╗ ██╗ ████████╗ ███████╗
520
+ ██╔══██╗ ██╔════╝ ██╔════╝ ████╗ ██║ ╚══██╔══╝ ██╔════╝
521
+ ███████║ ██║ ███╗ █████╗ ██╔██╗ ██║ ██║ ███████╗
522
+ ██╔══██║ ██║ ██║ ██╔══╝ ██║╚██╗██║ ██║ ╚════██║
523
+ ██║ ██║ ╚██████╔╝ ███████╗ ██║ ╚████║ ██║ ███████║
524
+ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═══╝ ╚═╝ ╚══════╝
525
+ v{__version__}
526
+ """
527
+ _ASCII_BANNER = f"""
528
+ ____ ____ ____ ____
529
+ | _ \\| ___|| ___|| _ \\
530
+ | | | | |_ | |_ | |_) |
531
+ | |_| | _| | _| | __/
532
+ |____/|____||____||_|
533
+
534
+ _ ____ ____ _ _ _____ ____
535
+ / \\ / ___|| ___|| \\ | ||_ _|/ ___|
536
+ / _ \\| | _ | |_ | \\| | | | \\___ \\
537
+ / ___ \\ |_| || _| | |\\ | | | ___) |
538
+ /_/ \\_\\____||____||_| \\_| |_| |____/
539
+ v{__version__}
540
+ """
541
+
542
+
543
+ def get_banner() -> str:
544
+ """Get the appropriate banner for the current charset mode.
545
+
546
+ Returns:
547
+ The text art banner string (Unicode or ASCII based on charset mode).
548
+
549
+ Includes "(local)" suffix when installed in editable mode.
550
+ """
551
+ if _detect_charset_mode() == CharsetMode.ASCII:
552
+ banner = _ASCII_BANNER
553
+ else:
554
+ banner = _UNICODE_BANNER
555
+
556
+ if _is_editable_install():
557
+ banner = banner.replace(f"v{__version__}", f"v{__version__} (local)")
558
+
559
+ return banner
560
+
561
+
562
+ MAX_ARG_LENGTH = 150
563
+ """Character limit for tool argument values in the UI.
564
+
565
+ Longer values are truncated with an ellipsis by `truncate_value`
566
+ in `tool_display`.
567
+ """
568
+
569
+ config: RunnableConfig = {
570
+ "recursion_limit": 1000,
571
+ }
572
+ """Default LangGraph runnable config.
573
+
574
+ Sets `recursion_limit` to 1000 to accommodate deeply nested agent graphs without
575
+ hitting the default LangGraph ceiling.
576
+ """
577
+
578
+ _git_branch_cache: dict[str, str | None] = {}
579
+ """Per-cwd cache of resolved git branch names.
580
+
581
+ Avoids repeated `git rev-parse` subprocess calls within the same session. Keyed
582
+ by `str(Path.cwd())`; `None` values indicate the directory is not inside a git
583
+ repository.
584
+ """
585
+
586
+
587
+ def _get_git_branch() -> str | None:
588
+ """Return the current git branch name, or `None` if not in a repo."""
589
+ import subprocess # noqa: S404
590
+
591
+ try:
592
+ cwd = str(Path.cwd())
593
+ except OSError:
594
+ logger.debug("Could not determine cwd for git branch lookup", exc_info=True)
595
+ return None
596
+ if cwd in _git_branch_cache:
597
+ return _git_branch_cache[cwd]
598
+
599
+ try:
600
+ result = subprocess.run(
601
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607
602
+ capture_output=True,
603
+ text=True,
604
+ timeout=2,
605
+ check=False,
606
+ )
607
+ if result.returncode == 0:
608
+ branch = result.stdout.strip() or None
609
+ _git_branch_cache[cwd] = branch
610
+ return branch
611
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
612
+ logger.debug("Could not determine git branch", exc_info=True)
613
+ _git_branch_cache[cwd] = None
614
+ return None
615
+
616
+
617
+ def build_stream_config(
618
+ thread_id: str,
619
+ assistant_id: str | None,
620
+ *,
621
+ sandbox_type: str | None = None,
622
+ ) -> RunnableConfig:
623
+ """Build the LangGraph stream config dict.
624
+
625
+ Injects CLI and SDK versions into `metadata["versions"]` so LangSmith traces
626
+ can be correlated with specific releases.
627
+
628
+ Why the CLI sets *both* versions:
629
+
630
+ * `create_deep_agent` bakes `versions: {"docagent": "X.Y.Z"}` into the
631
+ compiled graph via `with_config`. At stream time, LangGraph merges
632
+ the graph config with the runtime config passed here. Because the
633
+ metadata merge is shallow (effectively `{**graph_meta, **runtime_meta}`
634
+ for top-level keys), both configs containing a `versions` key means
635
+ the runtime dict **replaces** the graph dict entirely — the SDK
636
+ version would be lost.
637
+ * Including the SDK version here ensures it survives the merge.
638
+
639
+ Includes `ls_integration` metadata so LangSmith traces originating from the CLI
640
+ are distinguishable from bare SDK usage.
641
+
642
+ Args:
643
+ thread_id: The CLI session thread identifier.
644
+ assistant_id: The agent/assistant identifier, if any.
645
+ sandbox_type: Sandbox provider name for trace metadata, or `None` if no
646
+ sandbox is active.
647
+
648
+ Returns:
649
+ Config dict with `configurable` and `metadata` keys.
650
+ """
651
+ import contextlib
652
+ import importlib.metadata as importlib_metadata
653
+ from datetime import UTC, datetime
654
+
655
+ try:
656
+ cwd = str(Path.cwd())
657
+ except OSError:
658
+ logger.warning("Could not determine working directory", exc_info=True)
659
+ cwd = ""
660
+
661
+ # Include SDK version alongside CLI version — see docstring for why.
662
+ versions: dict[str, str] = {"docagent-cli": __version__}
663
+ with contextlib.suppress(importlib_metadata.PackageNotFoundError):
664
+ versions["docagent"] = importlib_metadata.version("docagent")
665
+
666
+ metadata: dict[str, Any] = {
667
+ "versions": versions,
668
+ "ls_integration": "docagent-cli",
669
+ }
670
+ from docagent_cli._env_vars import USER_ID
671
+
672
+ user_id = os.environ.get(USER_ID)
673
+ if user_id:
674
+ metadata["user_id"] = user_id
675
+ if cwd:
676
+ metadata["cwd"] = cwd
677
+ if assistant_id:
678
+ metadata.update(
679
+ {
680
+ "assistant_id": assistant_id,
681
+ "agent_name": assistant_id,
682
+ "updated_at": datetime.now(UTC).isoformat(),
683
+ }
684
+ )
685
+ branch = _get_git_branch()
686
+ if branch:
687
+ metadata["git_branch"] = branch
688
+ if sandbox_type and sandbox_type != "none":
689
+ metadata["sandbox_type"] = sandbox_type
690
+ return {
691
+ "configurable": {"thread_id": thread_id},
692
+ "metadata": metadata,
693
+ }
694
+
695
+
696
+ class _ShellAllowAll(list): # noqa: FURB189 # sentinel type, not a general-purpose list subclass
697
+ """Sentinel subclass for unrestricted shell access.
698
+
699
+ Using a dedicated type instead of a plain list lets consumers use
700
+ `isinstance` checks, which survive serialization/copy unlike identity
701
+ checks (`is`).
702
+ """
703
+
704
+
705
+ SHELL_ALLOW_ALL: list[str] = _ShellAllowAll(["__ALL__"])
706
+ """Sentinel value returned by `parse_shell_allow_list` for `--shell-allow-list=all`."""
707
+
708
+
709
+ def parse_shell_allow_list(allow_list_str: str | None) -> list[str] | None:
710
+ """Parse shell allow-list from string.
711
+
712
+ Args:
713
+ allow_list_str: Comma-separated list of commands, `'recommended'` for
714
+ safe defaults, or `'all'` to allow any command.
715
+
716
+ `'all'` must be the sole value — it is not recognized inside a
717
+ comma-separated list (unlike `'recommended'`).
718
+
719
+ Can also include `'recommended'` in the list to merge with custom
720
+ commands.
721
+
722
+ Returns:
723
+ List of allowed commands, `SHELL_ALLOW_ALL` if `'all'` was specified,
724
+ or `None` if no allow-list configured.
725
+
726
+ Raises:
727
+ ValueError: If `'all'` is combined with other commands.
728
+ """
729
+ if not allow_list_str:
730
+ return None
731
+
732
+ # Special value 'all' allows any shell command
733
+ if allow_list_str.strip().lower() == "all":
734
+ return SHELL_ALLOW_ALL
735
+
736
+ # Special value 'recommended' uses our curated safe list
737
+ if allow_list_str.strip().lower() == "recommended":
738
+ return list(RECOMMENDED_SAFE_SHELL_COMMANDS)
739
+
740
+ # Split by comma and strip whitespace
741
+ commands = [cmd.strip() for cmd in allow_list_str.split(",") if cmd.strip()]
742
+
743
+ # Reject ambiguous input: 'all' mixed with other commands
744
+ if any(cmd.lower() == "all" for cmd in commands):
745
+ msg = (
746
+ "Cannot combine 'all' with other commands in --shell-allow-list. "
747
+ "Use '--shell-allow-list all' alone to allow any command."
748
+ )
749
+ raise ValueError(msg)
750
+
751
+ # If "recommended" is in the list, merge with recommended commands
752
+ result = []
753
+ for cmd in commands:
754
+ if cmd.lower() == "recommended":
755
+ result.extend(RECOMMENDED_SAFE_SHELL_COMMANDS)
756
+ else:
757
+ result.append(cmd)
758
+
759
+ # Remove duplicates while preserving order
760
+ seen: set[str] = set()
761
+ unique: list[str] = []
762
+ for cmd in result:
763
+ if cmd not in seen:
764
+ seen.add(cmd)
765
+ unique.append(cmd)
766
+ return unique
767
+
768
+
769
+ def _read_config_toml_skills_dirs() -> list[str] | None:
770
+ """Read `[skills].extra_allowed_dirs` from `~/.docagent/config.toml`.
771
+
772
+ Returns:
773
+ List of path strings, or `None` if the key is absent or the file
774
+ cannot be read.
775
+ """
776
+ import tomllib
777
+
778
+ from docagent_cli.model_config import DEFAULT_CONFIG_PATH
779
+
780
+ try:
781
+ with DEFAULT_CONFIG_PATH.open("rb") as f:
782
+ data = tomllib.load(f)
783
+ except FileNotFoundError:
784
+ return None
785
+ except (PermissionError, OSError, tomllib.TOMLDecodeError):
786
+ logger.warning(
787
+ "Could not read skills config from %s",
788
+ DEFAULT_CONFIG_PATH,
789
+ exc_info=True,
790
+ )
791
+ return None
792
+
793
+ skills_section = data.get("skills", {})
794
+ dirs = skills_section.get("extra_allowed_dirs")
795
+ if isinstance(dirs, list):
796
+ return dirs
797
+ return None
798
+
799
+
800
+ def _parse_extra_skills_dirs(
801
+ env_raw: str | None,
802
+ config_toml_dirs: list[str] | None = None,
803
+ ) -> list[Path] | None:
804
+ """Merge extra skill directories from env var and config.toml.
805
+
806
+ Extra skills directories extend the containment allowlist used by
807
+ `load_skill_content` to validate that a resolved skill path lives inside a
808
+ trusted root. They do **not** add new skill discovery locations — skills are
809
+ still discovered only from the standard directories. This exists so that
810
+ symlinks inside standard skill directories can legitimately point to targets
811
+ in user-specified locations without being rejected by the path
812
+ containment check.
813
+
814
+ The env var (`DEEPAGENTS_CLI_EXTRA_SKILLS_DIRS`, colon-separated) takes
815
+ precedence: when set, `config.toml` values are ignored.
816
+
817
+ Args:
818
+ env_raw: Value of `DEEPAGENTS_CLI_EXTRA_SKILLS_DIRS` (colon-separated), or
819
+ `None` if unset.
820
+ config_toml_dirs: List of path strings from
821
+ `[skills].extra_allowed_dirs` in `~/.docagent/config.toml`.
822
+
823
+ Returns:
824
+ List of resolved `Path` objects, or `None` if not configured.
825
+ """
826
+ # Env var takes precedence when set
827
+ if env_raw:
828
+ dirs = [
829
+ Path(p.strip()).expanduser().resolve()
830
+ for p in env_raw.split(":")
831
+ if p.strip()
832
+ ]
833
+ return dirs or None
834
+
835
+ if config_toml_dirs:
836
+ dirs = [
837
+ Path(p).expanduser().resolve()
838
+ for p in config_toml_dirs
839
+ if isinstance(p, str) and p.strip()
840
+ ]
841
+ return dirs or None
842
+
843
+ return None
844
+
845
+
846
+ @dataclass
847
+ class Settings:
848
+ """Global settings and environment detection for docagent-cli.
849
+
850
+ This class is initialized once at startup and provides access to:
851
+ - Available models and API keys
852
+ - Current project information
853
+ - Tool availability (e.g., Tavily)
854
+ - File system paths
855
+ """
856
+
857
+ openai_api_key: str | None
858
+ """OpenAI API key if available."""
859
+
860
+ anthropic_api_key: str | None
861
+ """Anthropic API key if available."""
862
+
863
+ google_api_key: str | None
864
+ """Google API key if available."""
865
+
866
+ nvidia_api_key: str | None
867
+ """NVIDIA API key if available."""
868
+
869
+ tavily_api_key: str | None
870
+ """Tavily API key if available."""
871
+
872
+ google_cloud_project: str | None
873
+ """Google Cloud project ID for VertexAI authentication."""
874
+
875
+ docagent_langchain_project: str | None
876
+ """LangSmith project name for docagent agent tracing."""
877
+
878
+ user_langchain_project: str | None
879
+ """Original `LANGSMITH_PROJECT` from environment (for user code)."""
880
+
881
+ model_name: str | None = None
882
+ """Currently active model name, set after model creation."""
883
+
884
+ model_provider: str | None = None
885
+ """Provider identifier (e.g., `openai`, `anthropic`, `google_genai`)."""
886
+
887
+ model_context_limit: int | None = None
888
+ """Maximum input token count from the model profile."""
889
+
890
+ model_unsupported_modalities: frozenset[str] = frozenset()
891
+ """Input modalities not indicated as supported by the model profile."""
892
+
893
+ project_root: Path | None = None
894
+ """Current project root directory, or `None` if not in a git project."""
895
+
896
+ shell_allow_list: list[str] | None = None
897
+ """Shell commands that don't require user approval."""
898
+
899
+ extra_skills_dirs: list[Path] | None = None
900
+ """Extra directories added to the skill path containment allowlist.
901
+
902
+ These do NOT add new skill discovery locations — skills are still only
903
+ discovered from the standard directories. They exist so that symlinks inside
904
+ standard skill directories can point to targets in these additional
905
+ locations without being rejected by the containment check
906
+ in `load_skill_content`.
907
+
908
+ Set via `DEEPAGENTS_CLI_EXTRA_SKILLS_DIRS` env var (colon-separated) or
909
+ `[skills].extra_allowed_dirs` in `~/.docagent/config.toml`.
910
+ """
911
+
912
+ @classmethod
913
+ def from_environment(cls, *, start_path: Path | None = None) -> Settings:
914
+ """Create settings by detecting the current environment.
915
+
916
+ Args:
917
+ start_path: Directory to start project detection from (defaults to cwd)
918
+
919
+ Returns:
920
+ Settings instance with detected configuration
921
+ """
922
+ # Detect API keys (normalize empty strings to None).
923
+ from docagent_cli.model_config import resolve_env_var
924
+
925
+ openai_key = resolve_env_var("OPENAI_API_KEY")
926
+ anthropic_key = resolve_env_var("ANTHROPIC_API_KEY")
927
+ google_key = resolve_env_var("GOOGLE_API_KEY")
928
+ nvidia_key = resolve_env_var("NVIDIA_API_KEY")
929
+ tavily_key = resolve_env_var("TAVILY_API_KEY")
930
+ google_cloud_project = resolve_env_var("GOOGLE_CLOUD_PROJECT")
931
+
932
+ # Detect LangSmith configuration
933
+ # DEEPAGENTS_CLI_LANGSMITH_PROJECT: Project for docagent agent tracing
934
+ # user_langchain_project: User's ORIGINAL LANGSMITH_PROJECT (before override)
935
+ # When accessed via the module-level `settings` singleton,
936
+ # _ensure_bootstrap() has already run and may have overridden
937
+ # LANGSMITH_PROJECT. We use the saved original value, not the
938
+ # current os.environ value. Direct callers should ensure
939
+ # bootstrap has run if they depend on the override.
940
+ from docagent_cli._env_vars import (
941
+ EXTRA_SKILLS_DIRS,
942
+ LANGSMITH_PROJECT,
943
+ SHELL_ALLOW_LIST,
944
+ )
945
+
946
+ docagent_langchain_project = resolve_env_var(LANGSMITH_PROJECT)
947
+ user_langchain_project = _original_langsmith_project # Use saved original!
948
+
949
+ # Detect project
950
+ from docagent_cli.project_utils import find_project_root
951
+
952
+ project_root = find_project_root(start_path)
953
+
954
+ # Parse shell command allow-list from environment
955
+ # Format: comma-separated list of commands (e.g., "ls,cat,grep,pwd")
956
+
957
+ shell_allow_list_str = os.environ.get(SHELL_ALLOW_LIST)
958
+ shell_allow_list = parse_shell_allow_list(shell_allow_list_str)
959
+
960
+ # Parse extra skill containment roots from env var or config.toml.
961
+ # These extend the path allowlist for load_skill_content but do not
962
+ # add new skill discovery locations.
963
+ extra_skills_dirs = _parse_extra_skills_dirs(
964
+ os.environ.get(EXTRA_SKILLS_DIRS),
965
+ _read_config_toml_skills_dirs(),
966
+ )
967
+
968
+ return cls(
969
+ openai_api_key=openai_key,
970
+ anthropic_api_key=anthropic_key,
971
+ google_api_key=google_key,
972
+ nvidia_api_key=nvidia_key,
973
+ tavily_api_key=tavily_key,
974
+ google_cloud_project=google_cloud_project,
975
+ docagent_langchain_project=docagent_langchain_project,
976
+ user_langchain_project=user_langchain_project,
977
+ project_root=project_root,
978
+ shell_allow_list=shell_allow_list,
979
+ extra_skills_dirs=extra_skills_dirs,
980
+ )
981
+
982
+ def reload_from_environment(self, *, start_path: Path | None = None) -> list[str]:
983
+ """Reload selected settings from environment variables and project files.
984
+
985
+ This refreshes only fields that are expected to change at runtime
986
+ (API keys, Google Cloud project, project root, shell allow-list, and
987
+ LangSmith tracing project).
988
+
989
+ Runtime model state (`model_name`, `model_provider`,
990
+ `model_context_limit`) and the original user LangSmith project
991
+ (`user_langchain_project`) are intentionally preserved -- they are
992
+ not in `reloadable_fields` and are never touched by this method.
993
+
994
+ !!! note
995
+
996
+ `.env` files are loaded with `override=False`, so shell-exported
997
+ variables always take precedence. To override a shell-exported key
998
+ from `.env`, use the `DEEPAGENTS_CLI_` prefix (e.g.
999
+ `DEEPAGENTS_CLI_OPENAI_API_KEY`).
1000
+
1001
+ Args:
1002
+ start_path: Directory to start project detection from (defaults to cwd).
1003
+
1004
+ Returns:
1005
+ A list of human-readable change descriptions.
1006
+ """
1007
+ _load_dotenv(start_path=start_path)
1008
+
1009
+ api_key_fields = {
1010
+ "openai_api_key",
1011
+ "anthropic_api_key",
1012
+ "google_api_key",
1013
+ "nvidia_api_key",
1014
+ "tavily_api_key",
1015
+ }
1016
+ """Fields that hold API keys — used to mask values in change reports
1017
+ so secrets are not logged as plaintext."""
1018
+
1019
+ reloadable_fields = (
1020
+ "openai_api_key",
1021
+ "anthropic_api_key",
1022
+ "google_api_key",
1023
+ "nvidia_api_key",
1024
+ "tavily_api_key",
1025
+ "google_cloud_project",
1026
+ "docagent_langchain_project",
1027
+ "project_root",
1028
+ "shell_allow_list",
1029
+ "extra_skills_dirs",
1030
+ )
1031
+ """Fields refreshed on `/reload`.
1032
+
1033
+ Runtime model state (`model_name`, `model_provider`, `model_context_limit`)
1034
+ and the original user LangSmith project are intentionally excluded —
1035
+ they are set once and should not change across reloads.
1036
+ """
1037
+
1038
+ previous = {field: getattr(self, field) for field in reloadable_fields}
1039
+
1040
+ from docagent_cli._env_vars import (
1041
+ EXTRA_SKILLS_DIRS,
1042
+ LANGSMITH_PROJECT,
1043
+ SHELL_ALLOW_LIST,
1044
+ )
1045
+
1046
+ try:
1047
+ shell_allow_list = parse_shell_allow_list(os.environ.get(SHELL_ALLOW_LIST))
1048
+ except ValueError:
1049
+ logger.warning(
1050
+ "Invalid %s during reload; keeping previous value",
1051
+ SHELL_ALLOW_LIST,
1052
+ )
1053
+ shell_allow_list = previous["shell_allow_list"]
1054
+
1055
+ try:
1056
+ from docagent_cli.project_utils import find_project_root
1057
+
1058
+ project_root = find_project_root(start_path)
1059
+ except OSError:
1060
+ logger.warning(
1061
+ "Could not detect project root during reload; keeping previous value"
1062
+ )
1063
+ project_root = previous["project_root"]
1064
+
1065
+ from docagent_cli.model_config import resolve_env_var
1066
+
1067
+ refreshed = {
1068
+ "openai_api_key": resolve_env_var("OPENAI_API_KEY"),
1069
+ "anthropic_api_key": resolve_env_var("ANTHROPIC_API_KEY"),
1070
+ "google_api_key": resolve_env_var("GOOGLE_API_KEY"),
1071
+ "nvidia_api_key": resolve_env_var("NVIDIA_API_KEY"),
1072
+ "tavily_api_key": resolve_env_var("TAVILY_API_KEY"),
1073
+ "google_cloud_project": resolve_env_var("GOOGLE_CLOUD_PROJECT"),
1074
+ "docagent_langchain_project": resolve_env_var(LANGSMITH_PROJECT),
1075
+ "project_root": project_root,
1076
+ "shell_allow_list": shell_allow_list,
1077
+ "extra_skills_dirs": _parse_extra_skills_dirs(
1078
+ os.environ.get(EXTRA_SKILLS_DIRS),
1079
+ _read_config_toml_skills_dirs(),
1080
+ ),
1081
+ }
1082
+
1083
+ for field, value in refreshed.items():
1084
+ setattr(self, field, value)
1085
+
1086
+ # Sync the LANGSMITH_PROJECT env var so LangSmith tracing picks up
1087
+ # the change
1088
+ new_project = refreshed["docagent_langchain_project"]
1089
+ if new_project:
1090
+ os.environ["LANGSMITH_PROJECT"] = new_project
1091
+ elif previous["docagent_langchain_project"]:
1092
+ # Override was previously active but new value is unset; restore.
1093
+ if _original_langsmith_project:
1094
+ os.environ["LANGSMITH_PROJECT"] = _original_langsmith_project
1095
+ else:
1096
+ os.environ.pop("LANGSMITH_PROJECT", None)
1097
+
1098
+ def _display(field: str, value: object) -> str:
1099
+ if field in api_key_fields:
1100
+ return "set" if value else "unset"
1101
+ return str(value)
1102
+
1103
+ changes: list[str] = []
1104
+ for field in reloadable_fields:
1105
+ old_value = previous[field]
1106
+ new_value = refreshed[field]
1107
+ if old_value != new_value:
1108
+ changes.append(
1109
+ f"{field}: {_display(field, old_value)} -> "
1110
+ f"{_display(field, new_value)}"
1111
+ )
1112
+ return changes
1113
+
1114
+ @property
1115
+ def has_openai(self) -> bool:
1116
+ """Check if OpenAI API key is configured."""
1117
+ return self.openai_api_key is not None
1118
+
1119
+ @property
1120
+ def has_anthropic(self) -> bool:
1121
+ """Check if Anthropic API key is configured."""
1122
+ return self.anthropic_api_key is not None
1123
+
1124
+ @property
1125
+ def has_google(self) -> bool:
1126
+ """Check if Google API key is configured."""
1127
+ return self.google_api_key is not None
1128
+
1129
+ @property
1130
+ def has_nvidia(self) -> bool:
1131
+ """Check if NVIDIA API key is configured."""
1132
+ return self.nvidia_api_key is not None
1133
+
1134
+ @property
1135
+ def has_vertex_ai(self) -> bool:
1136
+ """Check if VertexAI is available (Google Cloud project set, no API key).
1137
+
1138
+ VertexAI uses Application Default Credentials (ADC) for authentication,
1139
+ so if GOOGLE_CLOUD_PROJECT is set and GOOGLE_API_KEY is not, we assume
1140
+ VertexAI.
1141
+ """
1142
+ return self.google_cloud_project is not None and self.google_api_key is None
1143
+
1144
+ @property
1145
+ def has_tavily(self) -> bool:
1146
+ """Check if Tavily API key is configured."""
1147
+ return self.tavily_api_key is not None
1148
+
1149
+ @property
1150
+ def user_docagent_dir(self) -> Path:
1151
+ """Get the base user-level .docagent directory.
1152
+
1153
+ Returns:
1154
+ Path to ~/.docagent
1155
+ """
1156
+ return Path.home() / ".docagent"
1157
+
1158
+ @staticmethod
1159
+ def get_user_agent_md_path(agent_name: str) -> Path:
1160
+ """Get user-level AGENTS.md path for a specific agent.
1161
+
1162
+ Returns path regardless of whether the file exists.
1163
+
1164
+ Args:
1165
+ agent_name: Name of the agent
1166
+
1167
+ Returns:
1168
+ Path to ~/.docagent/{agent_name}/AGENTS.md
1169
+ """
1170
+ return Path.home() / ".docagent" / agent_name / "AGENTS.md"
1171
+
1172
+ def get_project_agent_md_path(self) -> list[Path]:
1173
+ """Get project-level AGENTS.md paths.
1174
+
1175
+ Checks both `{project_root}/.docagent/AGENTS.md` and
1176
+ `{project_root}/AGENTS.md`, returning all that exist. If both are
1177
+ present, both are loaded and their instructions are combined, with
1178
+ `.docagent/AGENTS.md` first.
1179
+
1180
+ Returns:
1181
+ Existing AGENTS.md paths.
1182
+
1183
+ Empty if neither file exists or not in a project, one entry if
1184
+ only one is present, or two entries if both locations have the
1185
+ file.
1186
+ """
1187
+ if not self.project_root:
1188
+ return []
1189
+ from docagent_cli.project_utils import find_project_agent_md
1190
+
1191
+ return find_project_agent_md(self.project_root)
1192
+
1193
+ @staticmethod
1194
+ def _is_valid_agent_name(agent_name: str) -> bool:
1195
+ """Validate to prevent invalid filesystem paths and security issues.
1196
+
1197
+ Returns:
1198
+ True if the agent name is valid, False otherwise.
1199
+ """
1200
+ if not agent_name or not agent_name.strip():
1201
+ return False
1202
+ # Allow only alphanumeric, hyphens, underscores, and whitespace
1203
+ return bool(re.match(r"^[a-zA-Z0-9_\-\s]+$", agent_name))
1204
+
1205
+ def get_agent_dir(self, agent_name: str) -> Path:
1206
+ """Get the global agent directory path.
1207
+
1208
+ Args:
1209
+ agent_name: Name of the agent
1210
+
1211
+ Returns:
1212
+ Path to ~/.docagent/{agent_name}
1213
+
1214
+ Raises:
1215
+ ValueError: If the agent name contains invalid characters.
1216
+ """
1217
+ if not self._is_valid_agent_name(agent_name):
1218
+ msg = (
1219
+ f"Invalid agent name: {agent_name!r}. Agent names can only "
1220
+ "contain letters, numbers, hyphens, underscores, and spaces."
1221
+ )
1222
+ raise ValueError(msg)
1223
+ return Path.home() / ".docagent" / agent_name
1224
+
1225
+ def ensure_agent_dir(self, agent_name: str) -> Path:
1226
+ """Ensure the global agent directory exists and return its path.
1227
+
1228
+ Args:
1229
+ agent_name: Name of the agent
1230
+
1231
+ Returns:
1232
+ Path to ~/.docagent/{agent_name}
1233
+
1234
+ Raises:
1235
+ ValueError: If the agent name contains invalid characters.
1236
+ """
1237
+ if not self._is_valid_agent_name(agent_name):
1238
+ msg = (
1239
+ f"Invalid agent name: {agent_name!r}. Agent names can only "
1240
+ "contain letters, numbers, hyphens, underscores, and spaces."
1241
+ )
1242
+ raise ValueError(msg)
1243
+ agent_dir = self.get_agent_dir(agent_name)
1244
+ agent_dir.mkdir(parents=True, exist_ok=True)
1245
+ return agent_dir
1246
+
1247
+ def get_user_skills_dir(self, agent_name: str) -> Path:
1248
+ """Get user-level skills directory path for a specific agent.
1249
+
1250
+ Args:
1251
+ agent_name: Name of the agent
1252
+
1253
+ Returns:
1254
+ Path to ~/.docagent/{agent_name}/skills/
1255
+ """
1256
+ return self.get_agent_dir(agent_name) / "skills"
1257
+
1258
+ def ensure_user_skills_dir(self, agent_name: str) -> Path:
1259
+ """Ensure user-level skills directory exists and return its path.
1260
+
1261
+ Args:
1262
+ agent_name: Name of the agent
1263
+
1264
+ Returns:
1265
+ Path to ~/.docagent/{agent_name}/skills/
1266
+ """
1267
+ skills_dir = self.get_user_skills_dir(agent_name)
1268
+ skills_dir.mkdir(parents=True, exist_ok=True)
1269
+ return skills_dir
1270
+
1271
+ def get_project_skills_dir(self) -> Path | None:
1272
+ """Get project-level skills directory path.
1273
+
1274
+ Returns:
1275
+ Path to {project_root}/.docagent/skills/, or None if not in a project
1276
+ """
1277
+ if not self.project_root:
1278
+ return None
1279
+ return self.project_root / ".docagent" / "skills"
1280
+
1281
+ def ensure_project_skills_dir(self) -> Path | None:
1282
+ """Ensure project-level skills directory exists and return its path.
1283
+
1284
+ Returns:
1285
+ Path to {project_root}/.docagent/skills/, or None if not in a project
1286
+ """
1287
+ if not self.project_root:
1288
+ return None
1289
+ skills_dir = self.get_project_skills_dir()
1290
+ if skills_dir is None:
1291
+ return None
1292
+ skills_dir.mkdir(parents=True, exist_ok=True)
1293
+ return skills_dir
1294
+
1295
+ def get_user_agents_dir(self, agent_name: str) -> Path:
1296
+ """Get user-level agents directory path for custom subagent definitions.
1297
+
1298
+ Args:
1299
+ agent_name: Name of the CLI agent (e.g., "docagent")
1300
+
1301
+ Returns:
1302
+ Path to ~/.docagent/{agent_name}/agents/
1303
+ """
1304
+ return self.get_agent_dir(agent_name) / "agents"
1305
+
1306
+ def get_project_agents_dir(self) -> Path | None:
1307
+ """Get project-level agents directory path for custom subagent definitions.
1308
+
1309
+ Returns:
1310
+ Path to {project_root}/.docagent/agents/, or None if not in a project
1311
+ """
1312
+ if not self.project_root:
1313
+ return None
1314
+ return self.project_root / ".docagent" / "agents"
1315
+
1316
+ @property
1317
+ def user_agents_dir(self) -> Path:
1318
+ """Get the base user-level `.agents` directory (`~/.agents`).
1319
+
1320
+ Returns:
1321
+ Path to `~/.agents`
1322
+ """
1323
+ return Path.home() / ".agents"
1324
+
1325
+ def get_user_agent_skills_dir(self) -> Path:
1326
+ """Get user-level `~/.agents/skills/` directory.
1327
+
1328
+ This is a generic alias path for skills that is tool-agnostic.
1329
+
1330
+ Returns:
1331
+ Path to `~/.agents/skills/`
1332
+ """
1333
+ return self.user_agents_dir / "skills"
1334
+
1335
+ def get_project_agent_skills_dir(self) -> Path | None:
1336
+ """Get project-level `.agents/skills/` directory.
1337
+
1338
+ This is a generic alias path for skills that is tool-agnostic.
1339
+
1340
+ Returns:
1341
+ Path to `{project_root}/.agents/skills/`, or `None` if not in a project
1342
+ """
1343
+ if not self.project_root:
1344
+ return None
1345
+ return self.project_root / ".agents" / "skills"
1346
+
1347
+ @staticmethod
1348
+ def get_user_claude_skills_dir() -> Path:
1349
+ """Get user-level `~/.claude/skills/` directory (experimental).
1350
+
1351
+ Convenience bridge for cross-tool skill sharing with Claude Code.
1352
+ This is experimental and may be removed.
1353
+
1354
+ Returns:
1355
+ Path to `~/.claude/skills/`
1356
+ """
1357
+ return Path.home() / ".claude" / "skills"
1358
+
1359
+ def get_project_claude_skills_dir(self) -> Path | None:
1360
+ """Get project-level `.claude/skills/` directory (experimental).
1361
+
1362
+ Convenience bridge for cross-tool skill sharing with Claude Code.
1363
+ This is experimental and may be removed.
1364
+
1365
+ Returns:
1366
+ Path to `{project_root}/.claude/skills/`, or `None` if not in a project.
1367
+ """
1368
+ if not self.project_root:
1369
+ return None
1370
+ return self.project_root / ".claude" / "skills"
1371
+
1372
+ @staticmethod
1373
+ def get_built_in_skills_dir() -> Path:
1374
+ """Get the directory containing built-in skills that ship with the CLI.
1375
+
1376
+ Returns:
1377
+ Path to the `built_in_skills/` directory within the package.
1378
+ """
1379
+ return Path(__file__).parent / "built_in_skills"
1380
+
1381
+ def get_extra_skills_dirs(self) -> list[Path]:
1382
+ """Get user-configured extra skill directories.
1383
+
1384
+ Set via `DEEPAGENTS_CLI_EXTRA_SKILLS_DIRS` (colon-separated paths) or
1385
+ `[skills].extra_allowed_dirs` in `~/.docagent/config.toml`.
1386
+
1387
+ Returns:
1388
+ List of extra skill directory paths, or empty list if not configured.
1389
+ """
1390
+ return self.extra_skills_dirs or []
1391
+
1392
+
1393
+ class SessionState:
1394
+ """Mutable session state shared across the app, adapter, and agent.
1395
+
1396
+ Tracks runtime flags like auto-approve that can be toggled during a
1397
+ session via keybindings or the HITL approval menu's "Auto-approve all"
1398
+ option.
1399
+
1400
+ The `auto_approve` flag controls whether tool calls (shell execution, file
1401
+ writes/edits, web search, URL fetch) require user confirmation before running.
1402
+ """
1403
+
1404
+ def __init__(self, auto_approve: bool = False, no_splash: bool = False) -> None:
1405
+ """Initialize session state with optional flags.
1406
+
1407
+ Args:
1408
+ auto_approve: Whether to auto-approve tool calls without
1409
+ prompting.
1410
+
1411
+ Can be toggled at runtime via Shift+Tab or the HITL
1412
+ approval menu.
1413
+ no_splash: Whether to skip displaying the splash screen on startup.
1414
+ """
1415
+ self.auto_approve = auto_approve
1416
+ self.no_splash = no_splash
1417
+ self.exit_hint_until: float | None = None
1418
+ self.exit_hint_handle = None
1419
+ from docagent_cli.sessions import generate_thread_id
1420
+
1421
+ self.thread_id = generate_thread_id()
1422
+
1423
+ def toggle_auto_approve(self) -> bool:
1424
+ """Toggle auto-approve and return the new state.
1425
+
1426
+ Called by the Shift+Tab keybinding in the Textual app.
1427
+
1428
+ When auto-approve is on, all tool calls execute without prompting.
1429
+
1430
+ Returns:
1431
+ The new `auto_approve` state after toggling.
1432
+ """
1433
+ self.auto_approve = not self.auto_approve
1434
+ return self.auto_approve
1435
+
1436
+
1437
+ SHELL_TOOL_NAMES: frozenset[str] = frozenset({"bash", "shell", "execute"})
1438
+ """Tool names recognized as shell/command-execution tools.
1439
+
1440
+ Only `'execute'` is registered by the SDK and CLI backends in practice.
1441
+ `'bash'` and `'shell'` are legacy names carried over and kept as
1442
+ backwards-compatible aliases.
1443
+ """
1444
+
1445
+ DANGEROUS_SHELL_PATTERNS = (
1446
+ "$(", # Command substitution
1447
+ "`", # Backtick command substitution
1448
+ "$'", # ANSI-C quoting (can encode dangerous chars via escape sequences)
1449
+ "\n", # Newline (command injection)
1450
+ "\r", # Carriage return (command injection)
1451
+ "\t", # Tab (can be used for injection in some shells)
1452
+ "<(", # Process substitution (input)
1453
+ ">(", # Process substitution (output)
1454
+ "<<<", # Here-string
1455
+ "<<", # Here-doc (can embed commands)
1456
+ ">>", # Append redirect
1457
+ ">", # Output redirect
1458
+ "<", # Input redirect
1459
+ "${", # Variable expansion with braces (can run commands via ${var:-$(cmd)})
1460
+ )
1461
+ """Literal substrings that indicate shell injection risk.
1462
+
1463
+ Used by `contains_dangerous_patterns` to reject commands that embed arbitrary
1464
+ execution via redirects, substitution operators, or control characters — even
1465
+ when the base command is on the allow-list.
1466
+ """
1467
+
1468
+ RECOMMENDED_SAFE_SHELL_COMMANDS = (
1469
+ # Directory listing
1470
+ "ls",
1471
+ "dir",
1472
+ # File content viewing (read-only)
1473
+ "cat",
1474
+ "head",
1475
+ "tail",
1476
+ # Text searching (read-only)
1477
+ "grep",
1478
+ "wc",
1479
+ "strings",
1480
+ # Text processing (read-only, no shell execution)
1481
+ "cut",
1482
+ "tr",
1483
+ "diff",
1484
+ "md5sum",
1485
+ "sha256sum",
1486
+ # Path utilities
1487
+ "pwd",
1488
+ "which",
1489
+ # System info (read-only)
1490
+ "uname",
1491
+ "hostname",
1492
+ "whoami",
1493
+ "id",
1494
+ "groups",
1495
+ "uptime",
1496
+ "nproc",
1497
+ "lscpu",
1498
+ "lsmem",
1499
+ # Process viewing (read-only)
1500
+ "ps",
1501
+ )
1502
+ """Read-only commands auto-approved in non-interactive mode.
1503
+
1504
+ Only includes readers and formatters — shells, editors, interpreters, package
1505
+ managers, network tools, archivers, and anything on GTFOBins/LOOBins is
1506
+ intentionally excluded. File-write and injection vectors are blocked separately
1507
+ by `DANGEROUS_SHELL_PATTERNS`.
1508
+ """
1509
+
1510
+
1511
+ def contains_dangerous_patterns(command: str) -> bool:
1512
+ """Check if a command contains dangerous shell patterns.
1513
+
1514
+ These patterns can be used to bypass allow-list validation by embedding
1515
+ arbitrary commands within seemingly safe commands. The check includes
1516
+ both literal substring patterns (redirects, substitution operators, etc.)
1517
+ and regex patterns for bare variable expansion (`$VAR`) and the background
1518
+ operator (`&`).
1519
+
1520
+ Args:
1521
+ command: The shell command to check.
1522
+
1523
+ Returns:
1524
+ True if dangerous patterns are found, False otherwise.
1525
+ """
1526
+ if any(pattern in command for pattern in DANGEROUS_SHELL_PATTERNS):
1527
+ return True
1528
+
1529
+ # Bare variable expansion ($VAR without braces) can leak sensitive paths.
1530
+ # We already block ${ and $( above; this catches plain $HOME, $IFS, etc.
1531
+ if re.search(r"\$[A-Za-z_]", command):
1532
+ return True
1533
+
1534
+ # Standalone & (background execution) changes the execution model and
1535
+ # should not be allowed. We check for & that is NOT part of &&.
1536
+ return bool(re.search(r"(?<![&])&(?![&])", command))
1537
+
1538
+
1539
+ def is_shell_command_allowed(command: str, allow_list: list[str] | None) -> bool:
1540
+ """Check if a shell command is in the allow-list.
1541
+
1542
+ The allow-list matches against the first token of the command (the executable
1543
+ name). This allows read-only commands like ls, cat, grep, etc. to be
1544
+ auto-approved.
1545
+
1546
+ When `allow_list` is the `SHELL_ALLOW_ALL` sentinel, all non-empty commands
1547
+ are approved unconditionally — dangerous pattern checks are skipped.
1548
+
1549
+ SECURITY: For regular allow-lists, this function rejects commands containing
1550
+ dangerous shell patterns (command substitution, redirects, process
1551
+ substitution, etc.) BEFORE parsing, to prevent injection attacks that could
1552
+ bypass the allow-list.
1553
+
1554
+ Args:
1555
+ command: The full shell command to check.
1556
+ allow_list: List of allowed command names (e.g., `["ls", "cat", "grep"]`),
1557
+ the `SHELL_ALLOW_ALL` sentinel to allow any command, or `None`.
1558
+
1559
+ Returns:
1560
+ `True` if the command is allowed, `False` otherwise.
1561
+ """
1562
+ if not allow_list or not command or not command.strip():
1563
+ return False
1564
+
1565
+ # SHELL_ALLOW_ALL sentinel — skip pattern and token checks
1566
+ if isinstance(allow_list, _ShellAllowAll):
1567
+ return True
1568
+
1569
+ # SECURITY: Check for dangerous patterns BEFORE any parsing
1570
+ # This prevents injection attacks like: ls "$(rm -rf /)"
1571
+ if contains_dangerous_patterns(command):
1572
+ return False
1573
+
1574
+ allow_set = set(allow_list)
1575
+
1576
+ # Extract the first command token
1577
+ # Handle pipes and other shell operators by checking each command in the pipeline
1578
+ # Split by compound operators first (&&, ||), then single-char operators (|, ;).
1579
+ # Note: standalone & (background) is blocked by contains_dangerous_patterns above.
1580
+ segments = re.split(r"&&|\|\||[|;]", command)
1581
+
1582
+ # Track if we found at least one valid command
1583
+ found_command = False
1584
+
1585
+ for raw_segment in segments:
1586
+ segment = raw_segment.strip()
1587
+ if not segment:
1588
+ continue
1589
+
1590
+ try:
1591
+ # Try to parse as shell command to extract the executable name
1592
+ tokens = shlex.split(segment)
1593
+ if tokens:
1594
+ found_command = True
1595
+ cmd_name = tokens[0]
1596
+ # Check if this command is in the allow set
1597
+ if cmd_name not in allow_set:
1598
+ return False
1599
+ except ValueError:
1600
+ # If we can't parse it, be conservative and require approval
1601
+ return False
1602
+
1603
+ # All segments are allowed (and we found at least one command)
1604
+ return found_command
1605
+
1606
+
1607
+ def get_langsmith_project_name() -> str | None:
1608
+ """Resolve the LangSmith project name if tracing is configured.
1609
+
1610
+ Checks for the required API key and tracing environment variables.
1611
+ When both are present, resolves the project name with priority:
1612
+ `settings.docagent_langchain_project` (from
1613
+ `DEEPAGENTS_CLI_LANGSMITH_PROJECT`), then `LANGSMITH_PROJECT` from the
1614
+ environment (note: this may already have been overridden at bootstrap time
1615
+ to match `DEEPAGENTS_CLI_LANGSMITH_PROJECT`), then `'docagent-cli'`.
1616
+
1617
+ Returns:
1618
+ Project name string when LangSmith tracing is active, None otherwise.
1619
+ """
1620
+ from docagent_cli.model_config import resolve_env_var
1621
+
1622
+ langsmith_key = resolve_env_var("LANGSMITH_API_KEY") or resolve_env_var(
1623
+ "LANGCHAIN_API_KEY"
1624
+ )
1625
+ langsmith_tracing = resolve_env_var("LANGSMITH_TRACING") or resolve_env_var(
1626
+ "LANGCHAIN_TRACING_V2"
1627
+ )
1628
+ if not (langsmith_key and langsmith_tracing):
1629
+ return None
1630
+
1631
+ return (
1632
+ _get_settings().docagent_langchain_project
1633
+ or os.environ.get("LANGSMITH_PROJECT")
1634
+ or "docagent-cli"
1635
+ )
1636
+
1637
+
1638
+ def fetch_langsmith_project_url(project_name: str) -> str | None:
1639
+ """Fetch the LangSmith project URL via the LangSmith client.
1640
+
1641
+ Successful results are cached at module level so repeated calls do not
1642
+ make additional network requests.
1643
+
1644
+ The network call runs in a daemon thread with a hard timeout of
1645
+ `_LANGSMITH_URL_LOOKUP_TIMEOUT_SECONDS`, so this function blocks the
1646
+ calling thread for at most that duration even if LangSmith is unreachable.
1647
+
1648
+ Returns None (with a debug log) on any failure: missing `langsmith` package,
1649
+ network errors, invalid project names, client initialization issues,
1650
+ or timeouts.
1651
+
1652
+ Args:
1653
+ project_name: LangSmith project name to look up.
1654
+
1655
+ Returns:
1656
+ Project URL string if found, None otherwise.
1657
+ """
1658
+ global _langsmith_url_cache # noqa: PLW0603 # Module-level cache requires global statement
1659
+
1660
+ if _langsmith_url_cache is not None:
1661
+ cached_name, cached_url = _langsmith_url_cache
1662
+ if cached_name == project_name:
1663
+ return cached_url
1664
+ # Different project name — fall through to fetch.
1665
+
1666
+ try:
1667
+ from langsmith import Client
1668
+ except ImportError:
1669
+ logger.debug(
1670
+ "Could not fetch LangSmith project URL for '%s'",
1671
+ project_name,
1672
+ exc_info=True,
1673
+ )
1674
+ return None
1675
+
1676
+ result: str | None = None
1677
+ lookup_error: Exception | None = None
1678
+ done = threading.Event()
1679
+
1680
+ def _lookup_url() -> None:
1681
+ nonlocal result, lookup_error
1682
+ try:
1683
+ from docagent_cli.model_config import resolve_env_var
1684
+
1685
+ # Explicit api_key because Client() reads os.environ directly
1686
+ # and doesn't know about the DEEPAGENTS_CLI_ prefix.
1687
+ api_key = resolve_env_var("LANGSMITH_API_KEY") or resolve_env_var(
1688
+ "LANGCHAIN_API_KEY"
1689
+ )
1690
+ project = Client(api_key=api_key).read_project(project_name=project_name)
1691
+ result = project.url or None
1692
+ except Exception as exc: # noqa: BLE001 # LangSmith SDK error types are not stable
1693
+ lookup_error = exc
1694
+ finally:
1695
+ done.set()
1696
+
1697
+ thread = threading.Thread(target=_lookup_url, daemon=True)
1698
+ thread.start()
1699
+
1700
+ if not done.wait(_LANGSMITH_URL_LOOKUP_TIMEOUT_SECONDS):
1701
+ logger.debug(
1702
+ "Timed out fetching LangSmith project URL for '%s' after %.1fs",
1703
+ project_name,
1704
+ _LANGSMITH_URL_LOOKUP_TIMEOUT_SECONDS,
1705
+ )
1706
+ return None
1707
+
1708
+ if lookup_error is not None:
1709
+ logger.debug(
1710
+ "Could not fetch LangSmith project URL for '%s'",
1711
+ project_name,
1712
+ exc_info=(
1713
+ type(lookup_error),
1714
+ lookup_error,
1715
+ lookup_error.__traceback__,
1716
+ ),
1717
+ )
1718
+ return None
1719
+
1720
+ if result is not None:
1721
+ _langsmith_url_cache = (project_name, result)
1722
+ return result
1723
+
1724
+
1725
+ def build_langsmith_thread_url(thread_id: str) -> str | None:
1726
+ """Build a full LangSmith thread URL if tracing is configured.
1727
+
1728
+ Combines `get_langsmith_project_name` and `fetch_langsmith_project_url`
1729
+ into a single convenience helper.
1730
+
1731
+ Args:
1732
+ thread_id: Thread identifier to build the URL for.
1733
+
1734
+ Returns:
1735
+ Full thread URL string, or `None` if unavailable (LangSmith is not
1736
+ configured or the project URL cannot be resolved.)
1737
+ """
1738
+ project_name = get_langsmith_project_name()
1739
+ if not project_name:
1740
+ return None
1741
+
1742
+ project_url = fetch_langsmith_project_url(project_name)
1743
+ if not project_url:
1744
+ return None
1745
+
1746
+ return f"{project_url.rstrip('/')}/t/{thread_id}?utm_source=docagent-cli"
1747
+
1748
+
1749
+ def reset_langsmith_url_cache() -> None:
1750
+ """Reset the LangSmith URL cache (for testing)."""
1751
+ global _langsmith_url_cache # noqa: PLW0603 # Module-level cache requires global statement
1752
+ _langsmith_url_cache = None
1753
+
1754
+
1755
+ def get_default_coding_instructions() -> str:
1756
+ """Get the default coding agent instructions.
1757
+
1758
+ These are the immutable base instructions that cannot be modified by the agent.
1759
+ Long-term memory (AGENTS.md) is handled separately by the middleware.
1760
+
1761
+ Returns:
1762
+ The default agent instructions as a string.
1763
+ """
1764
+ default_prompt_path = Path(__file__).parent / "default_agent_prompt.md"
1765
+ return default_prompt_path.read_text()
1766
+
1767
+
1768
+ def detect_provider(model_name: str) -> str | None:
1769
+ """Auto-detect provider from model name.
1770
+
1771
+ Intentionally duplicates a subset of LangChain's
1772
+ `_attempt_infer_model_provider` because we need to resolve the provider
1773
+ **before** calling `init_chat_model` in order to:
1774
+
1775
+ 1. Build provider-specific kwargs (API base URLs, headers, etc.) that are
1776
+ passed *into* `init_chat_model`.
1777
+ 2. Validate credentials early to surface user-friendly errors.
1778
+
1779
+ Args:
1780
+ model_name: Model name to detect provider from.
1781
+
1782
+ Returns:
1783
+ Provider name (openai, anthropic, google_genai, google_vertexai,
1784
+ nvidia) or `None` if the provider cannot be determined from the
1785
+ name alone.
1786
+ """
1787
+ model_lower = model_name.lower()
1788
+
1789
+ if model_lower.startswith(("gpt-", "o1", "o3", "o4", "chatgpt")):
1790
+ return "openai"
1791
+
1792
+ if model_lower.startswith("claude"):
1793
+ s = _get_settings()
1794
+ if not s.has_anthropic and s.has_vertex_ai:
1795
+ return "google_vertexai"
1796
+ return "anthropic"
1797
+
1798
+ if model_lower.startswith("gemini"):
1799
+ s = _get_settings()
1800
+ if s.has_vertex_ai and not s.has_google:
1801
+ return "google_vertexai"
1802
+ return "google_genai"
1803
+
1804
+ if model_lower.startswith(("nemotron", "nvidia/")):
1805
+ return "nvidia"
1806
+
1807
+ return None
1808
+
1809
+
1810
+ def _get_default_model_spec() -> str:
1811
+ """Get default model specification based on available credentials.
1812
+
1813
+ Checks in order:
1814
+
1815
+ 1. `[models].default` in config file (user's intentional preference).
1816
+ 2. `[models].recent` in config file (last `/model` switch).
1817
+ 3. Auto-detection based on available API credentials.
1818
+
1819
+ Returns:
1820
+ Model specification in provider:model format.
1821
+
1822
+ Raises:
1823
+ ModelConfigError: If no credentials are configured.
1824
+ """
1825
+ from docagent_cli.model_config import ModelConfig, ModelConfigError
1826
+
1827
+ config = ModelConfig.load()
1828
+ if config.default_model:
1829
+ return config.default_model
1830
+
1831
+ if config.recent_model:
1832
+ return config.recent_model
1833
+
1834
+ s = _get_settings()
1835
+ if s.has_openai:
1836
+ return "openai:gpt-5.2"
1837
+ if s.has_anthropic:
1838
+ return "anthropic:claude-sonnet-4-6"
1839
+ if s.has_google:
1840
+ return "google_genai:gemini-3.1-pro-preview"
1841
+ if s.has_vertex_ai:
1842
+ return "google_vertexai:gemini-3.1-pro-preview"
1843
+ if s.has_nvidia:
1844
+ return "nvidia:nvidia/nemotron-3-super-120b-a12b"
1845
+
1846
+ msg = (
1847
+ "No credentials configured. Please set one of: "
1848
+ "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, "
1849
+ "GOOGLE_CLOUD_PROJECT, or NVIDIA_API_KEY"
1850
+ )
1851
+ raise ModelConfigError(msg)
1852
+
1853
+
1854
+ _OPENROUTER_APP_URL = "https://pypi.org/project/docagent-cli/"
1855
+ """Default `app_url` (maps to `HTTP-Referer`) for OpenRouter attribution.
1856
+
1857
+ See https://openrouter.ai/docs/app-attribution for details.
1858
+ """
1859
+
1860
+ _OPENROUTER_APP_TITLE = "Deep Agents CLI"
1861
+ """Default `app_title` (maps to `X-Title`) for OpenRouter attribution."""
1862
+
1863
+ _OPENROUTER_APP_CATEGORIES: list[str] = ["cli-agent"]
1864
+ """Default `app_categories` (maps to `X-OpenRouter-Categories`) for OpenRouter."""
1865
+
1866
+
1867
+ def _apply_openrouter_defaults(kwargs: dict[str, Any]) -> None:
1868
+ """Inject default OpenRouter attribution kwargs.
1869
+
1870
+ Sets `app_url` and `app_title` via `setdefault` so that user-supplied
1871
+ values in config take precedence. These map to the `HTTP-Referer` and
1872
+ `X-Title` headers that `ChatOpenRouter` sends for app attribution
1873
+ (see https://openrouter.ai/docs/app-attribution).
1874
+
1875
+ Users can override either value provider-wide or per-model in
1876
+ `~/.docagent/config.toml`:
1877
+
1878
+ ```toml
1879
+ # Provider-wide
1880
+ [models.providers.openrouter.params]
1881
+ app_url = "https://myapp.com"
1882
+ app_title = "My App"
1883
+
1884
+ # Per-model (shallow-merges on top of provider-wide)
1885
+ [models.providers.openrouter.params."openai/gpt-oss-120b"]
1886
+ app_title = "My App (GPT)"
1887
+ ```
1888
+
1889
+ Args:
1890
+ kwargs: Mutable kwargs dict to update in place.
1891
+ """
1892
+ kwargs.setdefault("app_url", _OPENROUTER_APP_URL)
1893
+ kwargs.setdefault("app_title", _OPENROUTER_APP_TITLE)
1894
+ kwargs.setdefault("app_categories", _OPENROUTER_APP_CATEGORIES)
1895
+
1896
+
1897
+ def _get_provider_kwargs(
1898
+ provider: str, *, model_name: str | None = None
1899
+ ) -> dict[str, Any]:
1900
+ """Get provider-specific kwargs from the config file.
1901
+
1902
+ Reads `base_url`, `api_key_env`, and the `params` table from the user's
1903
+ `config.toml` for the given provider.
1904
+
1905
+ When `model_name` is provided, per-model overrides from the `params`
1906
+ sub-table are shallow-merged on top.
1907
+
1908
+ Args:
1909
+ provider: Provider name (e.g., openai, anthropic, fireworks, ollama).
1910
+ model_name: Optional model name for per-model overrides.
1911
+
1912
+ Returns:
1913
+ Dictionary of provider-specific kwargs.
1914
+ """
1915
+ from docagent_cli.model_config import ModelConfig
1916
+
1917
+ config = ModelConfig.load()
1918
+ result: dict[str, Any] = config.get_kwargs(provider, model_name=model_name)
1919
+ base_url = config.get_base_url(provider)
1920
+ if base_url:
1921
+ result["base_url"] = base_url
1922
+ from docagent_cli.model_config import PROVIDER_API_KEY_ENV, resolve_env_var
1923
+
1924
+ api_key_env = config.get_api_key_env(provider)
1925
+ if not api_key_env:
1926
+ api_key_env = PROVIDER_API_KEY_ENV.get(provider)
1927
+ if api_key_env:
1928
+ logger.debug(
1929
+ "No api_key_env in config.toml for '%s';"
1930
+ " using hardcoded provider env var",
1931
+ provider,
1932
+ )
1933
+ if api_key_env:
1934
+ api_key = resolve_env_var(api_key_env)
1935
+ if api_key:
1936
+ result["api_key"] = api_key
1937
+
1938
+ if provider == "openrouter":
1939
+ from deepagents._models import check_openrouter_version # noqa: PLC2701
1940
+
1941
+ check_openrouter_version()
1942
+ _apply_openrouter_defaults(result)
1943
+
1944
+ return result
1945
+
1946
+
1947
+ def _create_model_from_class(
1948
+ class_path: str,
1949
+ model_name: str,
1950
+ provider: str,
1951
+ kwargs: dict[str, Any],
1952
+ ) -> BaseChatModel:
1953
+ """Import and instantiate a custom `BaseChatModel` class.
1954
+
1955
+ Args:
1956
+ class_path: Fully-qualified class in `module.path:ClassName` format.
1957
+ model_name: Model identifier to pass as `model` kwarg.
1958
+ provider: Provider name (for error messages).
1959
+ kwargs: Additional keyword arguments for the constructor.
1960
+
1961
+ Returns:
1962
+ Instantiated `BaseChatModel`.
1963
+
1964
+ Raises:
1965
+ ModelConfigError: If the class cannot be imported, is not a
1966
+ `BaseChatModel` subclass, or fails to instantiate.
1967
+ """
1968
+ from langchain_core.language_models import (
1969
+ BaseChatModel as _BaseChatModel, # Runtime import; module level is typing only
1970
+ )
1971
+
1972
+ from docagent_cli.model_config import ModelConfigError
1973
+
1974
+ if ":" not in class_path:
1975
+ msg = (
1976
+ f"Invalid class_path '{class_path}' for provider '{provider}': "
1977
+ "must be in module.path:ClassName format"
1978
+ )
1979
+ raise ModelConfigError(msg)
1980
+
1981
+ module_path, class_name = class_path.rsplit(":", 1)
1982
+
1983
+ try:
1984
+ module = importlib.import_module(module_path)
1985
+ except ImportError as e:
1986
+ msg = f"Could not import module '{module_path}' for provider '{provider}': {e}"
1987
+ raise ModelConfigError(msg) from e
1988
+
1989
+ cls = getattr(module, class_name, None)
1990
+ if cls is None:
1991
+ msg = (
1992
+ f"Class '{class_name}' not found in module '{module_path}' "
1993
+ f"for provider '{provider}'"
1994
+ )
1995
+ raise ModelConfigError(msg)
1996
+
1997
+ if not (isinstance(cls, type) and issubclass(cls, _BaseChatModel)):
1998
+ msg = (
1999
+ f"'{class_path}' is not a BaseChatModel subclass (got {type(cls).__name__})"
2000
+ )
2001
+ raise ModelConfigError(msg)
2002
+
2003
+ try:
2004
+ return cls(model=model_name, **kwargs)
2005
+ except Exception as e:
2006
+ msg = f"Failed to instantiate '{class_path}' for '{provider}:{model_name}': {e}"
2007
+ raise ModelConfigError(msg) from e
2008
+
2009
+
2010
+ def _create_model_via_init(
2011
+ model_name: str,
2012
+ provider: str,
2013
+ kwargs: dict[str, Any],
2014
+ ) -> BaseChatModel:
2015
+ """Create a model using langchain's `init_chat_model`.
2016
+
2017
+ Args:
2018
+ model_name: Model identifier.
2019
+ provider: Provider name (may be empty for auto-detection).
2020
+ kwargs: Additional keyword arguments.
2021
+
2022
+ Returns:
2023
+ Instantiated `BaseChatModel`.
2024
+
2025
+ Raises:
2026
+ ModelConfigError: On import, value, or runtime errors.
2027
+ """
2028
+ from langchain.chat_models import init_chat_model
2029
+
2030
+ from docagent_cli.model_config import ModelConfigError
2031
+
2032
+ try:
2033
+ if provider:
2034
+ return init_chat_model(model_name, model_provider=provider, **kwargs)
2035
+ return init_chat_model(model_name, **kwargs)
2036
+ except ImportError as e:
2037
+ import importlib.util
2038
+
2039
+ package_map = {
2040
+ "anthropic": "langchain-anthropic",
2041
+ "openai": "langchain-openai",
2042
+ "google_genai": "langchain-google-genai",
2043
+ "google_vertexai": "langchain-google-vertexai",
2044
+ "nvidia": "langchain-nvidia-ai-endpoints",
2045
+ }
2046
+ package = package_map.get(provider, f"langchain-{provider}")
2047
+ # Convert pip package name to Python module name for import check.
2048
+ module_name = package.replace("-", "_")
2049
+ try:
2050
+ spec_found = importlib.util.find_spec(module_name) is not None
2051
+ except (ImportError, ValueError):
2052
+ spec_found = False
2053
+ if spec_found:
2054
+ # Package is installed but an internal import failed — surface
2055
+ # the real error instead of the misleading "missing package" hint.
2056
+ msg = (
2057
+ f"Provider package '{package}' is installed but failed to "
2058
+ f"import for provider '{provider}': {e}"
2059
+ )
2060
+ else:
2061
+ msg = (
2062
+ f"Missing package for provider '{provider}'. "
2063
+ f"Install: pip install {package}"
2064
+ )
2065
+ raise ModelConfigError(msg) from e
2066
+ except (ValueError, TypeError) as e:
2067
+ spec = f"{provider}:{model_name}" if provider else model_name
2068
+ msg = f"Invalid model configuration for '{spec}': {e}"
2069
+ raise ModelConfigError(msg) from e
2070
+ except Exception as e: # provider SDK auth/network errors
2071
+ spec = f"{provider}:{model_name}" if provider else model_name
2072
+ msg = f"Failed to initialize model '{spec}': {e}"
2073
+ raise ModelConfigError(msg) from e
2074
+
2075
+
2076
+ @dataclass(frozen=True)
2077
+ class ModelResult:
2078
+ """Result of creating a chat model, bundling the model with its metadata.
2079
+
2080
+ This separates model creation from settings mutation so callers can decide
2081
+ when to commit the metadata to global settings.
2082
+
2083
+ Attributes:
2084
+ model: The instantiated chat model.
2085
+ model_name: Resolved model name.
2086
+ provider: Resolved provider name.
2087
+ context_limit: Max input tokens from the model profile, or `None`.
2088
+ unsupported_modalities: Input modalities not indicated as supported by
2089
+ the model profile (e.g. `{"audio", "video"}`).
2090
+ """
2091
+
2092
+ model: BaseChatModel
2093
+ model_name: str
2094
+ provider: str
2095
+ context_limit: int | None = None
2096
+ unsupported_modalities: frozenset[str] = frozenset()
2097
+
2098
+ def apply_to_settings(self) -> None:
2099
+ """Commit this result's metadata to global `settings`."""
2100
+ s = _get_settings()
2101
+ s.model_name = self.model_name
2102
+ s.model_provider = self.provider
2103
+ s.model_context_limit = self.context_limit
2104
+ s.model_unsupported_modalities = self.unsupported_modalities
2105
+
2106
+
2107
+ def _apply_profile_overrides(
2108
+ model: BaseChatModel,
2109
+ overrides: dict[str, Any],
2110
+ model_name: str,
2111
+ *,
2112
+ label: str,
2113
+ raise_on_failure: bool = False,
2114
+ ) -> None:
2115
+ """Merge `overrides` into `model.profile`.
2116
+
2117
+ If the model already has a dict profile, overrides are layered on top
2118
+ so existing keys (e.g., `tool_calling`) are preserved unchanged.
2119
+
2120
+ Args:
2121
+ model: The chat model whose profile will be updated.
2122
+ overrides: Key/value pairs to merge into the profile.
2123
+ model_name: Model name used in log/error messages.
2124
+ label: Human-readable source label for messages
2125
+ (e.g., `"config.toml"`, `"CLI --profile-override"`).
2126
+ raise_on_failure: When `True`, raise `ModelConfigError` instead
2127
+ of logging a warning if assignment fails.
2128
+
2129
+ Raises:
2130
+ ModelConfigError: If `raise_on_failure` is `True` and the model
2131
+ rejects profile assignment.
2132
+ """
2133
+ from docagent_cli.model_config import ModelConfigError
2134
+
2135
+ logger.debug("Applying %s profile overrides: %s", label, overrides)
2136
+ profile = getattr(model, "profile", None)
2137
+ merged = {**profile, **overrides} if isinstance(profile, dict) else overrides
2138
+ try:
2139
+ model.profile = merged # type: ignore[union-attr]
2140
+ except (AttributeError, TypeError, ValueError) as exc:
2141
+ if raise_on_failure:
2142
+ msg = (
2143
+ f"Could not apply {label} to model '{model_name}': {exc}. "
2144
+ f"The model may not support profile assignment."
2145
+ )
2146
+ raise ModelConfigError(msg) from exc
2147
+ logger.warning(
2148
+ "Could not apply %s profile overrides to model '%s': %s. "
2149
+ "Overrides will be ignored.",
2150
+ label,
2151
+ model_name,
2152
+ exc,
2153
+ )
2154
+
2155
+
2156
+ def create_model(
2157
+ model_spec: str | None = None,
2158
+ *,
2159
+ extra_kwargs: dict[str, Any] | None = None,
2160
+ profile_overrides: dict[str, Any] | None = None,
2161
+ ) -> ModelResult:
2162
+ """Create a chat model.
2163
+
2164
+ Uses `init_chat_model` for standard providers, or imports a custom
2165
+ `BaseChatModel` subclass when the provider has a `class_path` in config.
2166
+
2167
+ Supports `provider:model` format (e.g., `'anthropic:claude-sonnet-4-5'`)
2168
+ for explicit provider selection, or bare model names for auto-detection.
2169
+
2170
+ Args:
2171
+ model_spec: Model specification in `provider:model` format (e.g.,
2172
+ `'anthropic:claude-sonnet-4-5'`, `'openai:gpt-4o'`) or just the model
2173
+ name for auto-detection (e.g., `'claude-sonnet-4-5'`).
2174
+
2175
+ If not provided, uses environment-based defaults.
2176
+ extra_kwargs: Additional kwargs to pass to the model constructor.
2177
+
2178
+ These take highest priority, overriding values from the config file.
2179
+ profile_overrides: Extra profile fields from `--profile-override`.
2180
+
2181
+ Merged on top of config file profile overrides (CLI wins).
2182
+
2183
+ Returns:
2184
+ A `ModelResult` containing the model and its metadata.
2185
+
2186
+ Raises:
2187
+ ModelConfigError: If provider cannot be determined from the model name,
2188
+ required provider package is not installed, or no credentials are
2189
+ configured.
2190
+
2191
+ Examples:
2192
+ >>> model = create_model("anthropic:claude-sonnet-4-5")
2193
+ >>> model = create_model("openai:gpt-4o")
2194
+ >>> model = create_model("gpt-4o") # Auto-detects openai
2195
+ >>> model = create_model() # Uses environment defaults
2196
+ """
2197
+ from docagent_cli.model_config import ModelConfig, ModelConfigError, ModelSpec
2198
+
2199
+ if not model_spec:
2200
+ model_spec = _get_default_model_spec()
2201
+
2202
+ # Parse provider:model syntax
2203
+ provider: str
2204
+ model_name: str
2205
+ parsed = ModelSpec.try_parse(model_spec)
2206
+ if parsed:
2207
+ # Explicit provider:model (e.g., "anthropic:claude-sonnet-4-5")
2208
+ provider, model_name = parsed.provider, parsed.model
2209
+ elif ":" in model_spec:
2210
+ # Contains colon but ModelSpec rejected it (empty provider or model)
2211
+ _, _, after = model_spec.partition(":")
2212
+ if after:
2213
+ # Leading colon (e.g., ":claude-opus-4-6") — treat as bare model name
2214
+ model_name = after
2215
+ provider = detect_provider(model_name) or ""
2216
+ else:
2217
+ msg = (
2218
+ f"Invalid model spec '{model_spec}': model name is required "
2219
+ "(e.g., 'anthropic:claude-sonnet-4-5' or 'claude-sonnet-4-5')"
2220
+ )
2221
+ raise ModelConfigError(msg)
2222
+ else:
2223
+ # Bare model name — auto-detect provider or let init_chat_model infer
2224
+ model_name = model_spec
2225
+ provider = detect_provider(model_spec) or ""
2226
+
2227
+ # Provider-specific kwargs (with per-model overrides)
2228
+ kwargs = _get_provider_kwargs(provider, model_name=model_name)
2229
+
2230
+ # CLI --model-params take highest priority
2231
+ if extra_kwargs:
2232
+ kwargs.update(extra_kwargs)
2233
+
2234
+ # Check if this provider uses a custom BaseChatModel class
2235
+ config = ModelConfig.load()
2236
+ class_path = config.get_class_path(provider) if provider else None
2237
+
2238
+ if class_path:
2239
+ model = _create_model_from_class(class_path, model_name, provider, kwargs)
2240
+ else:
2241
+ model = _create_model_via_init(model_name, provider, kwargs)
2242
+
2243
+ resolved_provider = provider or getattr(model, "_model_provider", provider)
2244
+
2245
+ # Apply profile overrides from config.toml (e.g., max_input_tokens)
2246
+ if provider:
2247
+ config_profile_overrides = config.get_profile_overrides(
2248
+ provider, model_name=model_name
2249
+ )
2250
+ if config_profile_overrides:
2251
+ _apply_profile_overrides(
2252
+ model,
2253
+ config_profile_overrides,
2254
+ model_name,
2255
+ label=f"config.toml (provider '{provider}')",
2256
+ )
2257
+
2258
+ # CLI --profile-override takes highest priority (on top of config.toml)
2259
+ if profile_overrides:
2260
+ _apply_profile_overrides(
2261
+ model,
2262
+ profile_overrides,
2263
+ model_name,
2264
+ label="CLI --profile-override",
2265
+ raise_on_failure=True,
2266
+ )
2267
+
2268
+ # Extract context limit and modality support from model profile
2269
+ context_limit: int | None = None
2270
+ unsupported_modalities: frozenset[str] = frozenset()
2271
+ profile = getattr(model, "profile", None)
2272
+ if isinstance(profile, dict):
2273
+ if isinstance(profile.get("max_input_tokens"), int):
2274
+ context_limit = profile["max_input_tokens"]
2275
+
2276
+ modality_keys = {
2277
+ "image_inputs": "image",
2278
+ "audio_inputs": "audio",
2279
+ "video_inputs": "video",
2280
+ "pdf_inputs": "pdf",
2281
+ }
2282
+ unsupported_modalities = frozenset(
2283
+ label for key, label in modality_keys.items() if profile.get(key) is False
2284
+ )
2285
+
2286
+ return ModelResult(
2287
+ model=model,
2288
+ model_name=model_name,
2289
+ provider=resolved_provider,
2290
+ context_limit=context_limit,
2291
+ unsupported_modalities=unsupported_modalities,
2292
+ )
2293
+
2294
+
2295
+ def validate_model_capabilities(model: BaseChatModel, model_name: str) -> None:
2296
+ """Validate that the model has required capabilities for `docagent`.
2297
+
2298
+ Checks the model's profile (if available) to ensure it supports tool calling, which
2299
+ is required for agent functionality. Issues warnings for models without profiles or
2300
+ with limited context windows.
2301
+
2302
+ Args:
2303
+ model: The instantiated model to validate.
2304
+ model_name: Model name for error/warning messages.
2305
+
2306
+ Note:
2307
+ This validation is best-effort. Models without profiles will pass with
2308
+ a warning. Exits via sys.exit(1) if model profile explicitly indicates
2309
+ tool_calling=False.
2310
+ """
2311
+ console = _get_console()
2312
+ profile = getattr(model, "profile", None)
2313
+
2314
+ if profile is None:
2315
+ # Model doesn't have profile data - warn but allow
2316
+ console.print(
2317
+ f"[dim][yellow]Note:[/yellow] No capability profile for "
2318
+ f"'{model_name}'. Cannot verify tool calling support.[/dim]"
2319
+ )
2320
+ return
2321
+
2322
+ if not isinstance(profile, dict):
2323
+ return
2324
+
2325
+ # Check required capability: tool_calling
2326
+ tool_calling = profile.get("tool_calling")
2327
+ if tool_calling is False:
2328
+ console.print(
2329
+ f"[bold red]Error:[/bold red] Model '{model_name}' "
2330
+ "does not support tool calling."
2331
+ )
2332
+ console.print(
2333
+ "\nDeep Agents requires tool calling for agent functionality. "
2334
+ "Please choose a model that supports tool calling."
2335
+ )
2336
+ console.print("\nSee MODELS.md for supported models.")
2337
+ sys.exit(1)
2338
+
2339
+ # Warn about potentially limited context (< 8k tokens)
2340
+ max_input_tokens = profile.get("max_input_tokens")
2341
+ if max_input_tokens and max_input_tokens < 8000: # noqa: PLR2004 # Model context window default
2342
+ console.print(
2343
+ f"[dim][yellow]Warning:[/yellow] Model '{model_name}' has limited context "
2344
+ f"({max_input_tokens:,} tokens). Agent performance may be affected.[/dim]"
2345
+ )
2346
+
2347
+
2348
+ def _get_console() -> Console:
2349
+ """Return the lazily-initialized global `Console` instance.
2350
+
2351
+ Defers the `rich.console` import until console output is actually
2352
+ needed. The result is cached in `globals()["console"]`.
2353
+
2354
+ Returns:
2355
+ The global Rich `Console` singleton.
2356
+ """
2357
+ cached = globals().get("console")
2358
+ if cached is not None:
2359
+ return cached
2360
+ with _singleton_lock:
2361
+ cached = globals().get("console")
2362
+ if cached is not None:
2363
+ return cached
2364
+ from rich.console import Console
2365
+
2366
+ inst = Console(highlight=False)
2367
+ globals()["console"] = inst
2368
+ return inst
2369
+
2370
+
2371
+ def _get_settings() -> Settings:
2372
+ """Return the lazily-initialized global `Settings` instance.
2373
+
2374
+ Ensures bootstrap has run before constructing settings. The result is cached
2375
+ in `globals()["settings"]` so subsequent access — including
2376
+ `from config import settings` in other modules — resolves instantly.
2377
+
2378
+ Returns:
2379
+ The global `Settings` singleton.
2380
+ """
2381
+ cached = globals().get("settings")
2382
+ if cached is not None:
2383
+ return cached
2384
+ with _singleton_lock:
2385
+ cached = globals().get("settings")
2386
+ if cached is not None:
2387
+ return cached
2388
+ _ensure_bootstrap()
2389
+ try:
2390
+ inst = Settings.from_environment(start_path=_bootstrap_start_path)
2391
+ except Exception:
2392
+ logger.exception(
2393
+ "Failed to initialize settings from environment (start_path=%s)",
2394
+ _bootstrap_start_path,
2395
+ )
2396
+ raise
2397
+ globals()["settings"] = inst
2398
+ return inst
2399
+
2400
+
2401
+ def __getattr__(name: str) -> Settings | Console:
2402
+ """Lazy module attributes for `settings` and `console`.
2403
+
2404
+ Defers heavy initialization until first access. Subsequent accesses hit
2405
+ the module-level attribute directly (no `__getattr__` overhead).
2406
+
2407
+ Returns:
2408
+ The requested lazy singleton.
2409
+
2410
+ Raises:
2411
+ AttributeError: If *name* is not a lazily-provided attribute.
2412
+ """
2413
+ if name == "settings":
2414
+ return _get_settings()
2415
+ if name == "console":
2416
+ return _get_console()
2417
+ msg = f"module {__name__!r} has no attribute {name!r}"
2418
+ raise AttributeError(msg)