timbal 2.0.2__tar.gz → 2.0.4__tar.gz

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 (207) hide show
  1. {timbal-2.0.2 → timbal-2.0.4}/.gitignore +1 -0
  2. {timbal-2.0.2 → timbal-2.0.4}/PKG-INFO +1 -1
  3. {timbal-2.0.2 → timbal-2.0.4}/pyproject.toml +1 -0
  4. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/__init__.py +4 -2
  5. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/_version.py +2 -2
  6. timbal-2.0.4/python/timbal/collectors/impl/timbal.py +84 -0
  7. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/__init__.py +5 -0
  8. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/agent.py +121 -20
  9. timbal-2.0.4/python/timbal/core/fallback_model.py +133 -0
  10. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/llm_router.py +53 -4
  11. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/runnable.py +401 -13
  12. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/workflow.py +36 -8
  13. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/errors.py +29 -0
  14. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/platform/utils.py +14 -0
  15. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/context.py +53 -8
  16. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/exporters/otel.py +100 -28
  17. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/providers/base.py +16 -0
  18. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/providers/jsonl.py +64 -0
  19. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/providers/sqlite.py +72 -11
  20. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/__init__.py +1 -0
  21. timbal-2.0.4/python/timbal/types/approval.py +71 -0
  22. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/events/__init__.py +2 -1
  23. timbal-2.0.4/python/timbal/types/events/approval.py +30 -0
  24. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/file.py +1 -5
  25. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/utils/model.py +7 -1
  26. timbal-2.0.2/python/timbal/collectors/impl/timbal.py +0 -50
  27. {timbal-2.0.2 → timbal-2.0.4}/LICENSE +0 -0
  28. {timbal-2.0.2 → timbal-2.0.4}/README.md +0 -0
  29. {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/chat-completions.mdx +0 -0
  30. {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/create-from-workforce.mdx +0 -0
  31. {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/jobs/cancel.mdx +0 -0
  32. {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/jobs/get.mdx +0 -0
  33. {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/jobs/list.mdx +0 -0
  34. {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/jobs/retry.mdx +0 -0
  35. {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/link.mdx +0 -0
  36. {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/list-context-vars.mdx +0 -0
  37. {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/list-policies.mdx +0 -0
  38. {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/messages.mdx +0 -0
  39. {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/responses.mdx +0 -0
  40. {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/unlink.mdx +0 -0
  41. {timbal-2.0.2 → timbal-2.0.4}/pyrightconfig.json +0 -0
  42. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/__init__.py +0 -0
  43. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/__main__.py +0 -0
  44. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/cst_utils.py +0 -0
  45. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/flow.py +0 -0
  46. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/format.py +0 -0
  47. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/model_discovery.py +0 -0
  48. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/test.py +0 -0
  49. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/tool_discovery.py +0 -0
  50. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/__init__.py +0 -0
  51. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/add_edge.py +0 -0
  52. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/add_step.py +0 -0
  53. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/add_tool.py +0 -0
  54. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/convert_to_workflow.py +0 -0
  55. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/remove_edge.py +0 -0
  56. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/remove_step.py +0 -0
  57. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/remove_tool.py +0 -0
  58. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/set_config.py +0 -0
  59. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/set_param.py +0 -0
  60. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/set_position.py +0 -0
  61. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/__init__.py +0 -0
  62. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/base.py +0 -0
  63. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/impl/__init__.py +0 -0
  64. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/impl/anthropic.py +0 -0
  65. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/impl/default.py +0 -0
  66. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/impl/message.py +0 -0
  67. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/impl/openai.py +0 -0
  68. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/impl/string.py +0 -0
  69. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/registry.py +0 -0
  70. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/mcp.py +0 -0
  71. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/memory_compaction.py +0 -0
  72. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/models.py +0 -0
  73. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/skill.py +0 -0
  74. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/test_model.py +0 -0
  75. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/tool.py +0 -0
  76. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/tool_set.py +0 -0
  77. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/__init__.py +0 -0
  78. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/cli.py +0 -0
  79. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/display.py +0 -0
  80. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/agent.py +0 -0
  81. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/eval_examples.yaml +0 -0
  82. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/eval_flow_validators.yaml +0 -0
  83. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/eval_negations.yaml +0 -0
  84. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/eval_parallel.yaml +0 -0
  85. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/eval_subagent.yaml +0 -0
  86. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/eval_transforms.yaml +0 -0
  87. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/test_invalid_validator.yaml +0 -0
  88. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/models.py +0 -0
  89. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/runner.py +0 -0
  90. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/utils.py +0 -0
  91. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/__init__.py +0 -0
  92. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/base.py +0 -0
  93. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/comparison_base.py +0 -0
  94. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/contains.py +0 -0
  95. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/contains_all.py +0 -0
  96. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/contains_any.py +0 -0
  97. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/context.py +0 -0
  98. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/email.py +0 -0
  99. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/ends_with.py +0 -0
  100. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/eq.py +0 -0
  101. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/gt.py +0 -0
  102. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/gte.py +0 -0
  103. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/json.py +0 -0
  104. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/language.py +0 -0
  105. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/length.py +0 -0
  106. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/llm_base.py +0 -0
  107. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/lt.py +0 -0
  108. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/lte.py +0 -0
  109. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/max_length.py +0 -0
  110. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/min_length.py +0 -0
  111. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/not_null.py +0 -0
  112. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/parallel.py +0 -0
  113. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/pattern.py +0 -0
  114. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/prompt.py +0 -0
  115. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/semantic.py +0 -0
  116. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/seq.py +0 -0
  117. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/starts_with.py +0 -0
  118. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/type.py +0 -0
  119. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/logs.py +0 -0
  120. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/models.yaml +0 -0
  121. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/platform/__init__.py +0 -0
  122. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/platform/integrations.py +0 -0
  123. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/platform/knowledge_bases/__init__.py +0 -0
  124. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/platform/knowledge_bases/query.py +0 -0
  125. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/platform/types.py +0 -0
  126. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/server/__init__.py +0 -0
  127. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/server/__main__.py +0 -0
  128. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/server/http.py +0 -0
  129. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/server/jobs.py +0 -0
  130. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/server/voice.html +0 -0
  131. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/server/voice.py +0 -0
  132. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/__init__.py +0 -0
  133. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/config.py +0 -0
  134. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/config_loader.py +0 -0
  135. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/dependency_analyzer.py +0 -0
  136. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/__init__.py +0 -0
  137. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/exporters/__init__.py +0 -0
  138. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/providers/__init__.py +0 -0
  139. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/providers/in_memory.py +0 -0
  140. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/providers/platform.py +0 -0
  141. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/span.py +0 -0
  142. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/trace.py +0 -0
  143. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/__init__.py +0 -0
  144. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/asana.py +0 -0
  145. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/bash.py +0 -0
  146. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/cala.py +0 -0
  147. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/cloudflare.py +0 -0
  148. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/dynamics_business_central.py +0 -0
  149. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/dynamics_sales.py +0 -0
  150. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/edit.py +0 -0
  151. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/elasticsearch.py +0 -0
  152. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/elevenlabs.py +0 -0
  153. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/excel.py +0 -0
  154. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/fal.py +0 -0
  155. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/firecrawl.py +0 -0
  156. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/gemini_images.py +0 -0
  157. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/gmail.py +0 -0
  158. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/google_calendar.py +0 -0
  159. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/google_docs.py +0 -0
  160. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/google_drive.py +0 -0
  161. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/google_maps.py +0 -0
  162. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/google_sheets.py +0 -0
  163. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/hubspot.py +0 -0
  164. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/knowledge_base.py +0 -0
  165. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/lancedb.py +0 -0
  166. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/linkedin.py +0 -0
  167. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/mongodb.py +0 -0
  168. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/netsuite.py +0 -0
  169. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/onedrive.py +0 -0
  170. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/outlook.py +0 -0
  171. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/pinecone.py +0 -0
  172. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/powerbi.py +0 -0
  173. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/read.py +0 -0
  174. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/replicate.py +0 -0
  175. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/salesforce.py +0 -0
  176. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/scraperapi.py +0 -0
  177. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/shopify.py +0 -0
  178. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/slack.py +0 -0
  179. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/stripe.py +0 -0
  180. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/tavily.py +0 -0
  181. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/web_search.py +0 -0
  182. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/write.py +0 -0
  183. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/xai.py +0 -0
  184. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/zendesk.py +0 -0
  185. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/zoho_crm.py +0 -0
  186. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/__init__.py +0 -0
  187. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/base.py +0 -0
  188. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/custom.py +0 -0
  189. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/file.py +0 -0
  190. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/text.py +0 -0
  191. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/thinking.py +0 -0
  192. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/tool_result.py +0 -0
  193. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/tool_use.py +0 -0
  194. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/events/base.py +0 -0
  195. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/events/delta.py +0 -0
  196. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/events/output.py +0 -0
  197. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/events/start.py +0 -0
  198. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/message.py +0 -0
  199. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/run_status.py +0 -0
  200. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/utils/__init__.py +0 -0
  201. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/utils/import_spec.py +0 -0
  202. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/utils/net.py +0 -0
  203. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/utils/schema.py +0 -0
  204. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/utils/serialization.py +0 -0
  205. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/voice/__init__.py +0 -0
  206. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/voice/elevenlabs.py +0 -0
  207. {timbal-2.0.2 → timbal-2.0.4}/python/timbal/voice/session.py +0 -0
@@ -48,6 +48,7 @@ node_modules/
48
48
  **/*lancedb*/
49
49
 
50
50
  **/NOTES.md
51
+ **/LINKEDIN.md
51
52
 
52
53
 
53
54
  # Internal planning docs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: timbal
3
- Version: 2.0.2
3
+ Version: 2.0.4
4
4
  Summary: Simple, performant, battle-tested framework for building reliable AI applications
5
5
  Project-URL: Source, https://github.com/timbal-ai/timbal
6
6
  Author-email: Timbal <team@timbal.ai>
@@ -72,6 +72,7 @@ dev = [
72
72
  "pyngrok>=7.2.3",
73
73
  "pytest>=8.3.4",
74
74
  "pytest-asyncio>=0.25.2",
75
+ "pytest-cov>=7.0.0",
75
76
  "yappi>=1.7.6",
76
77
  ]
77
78
 
@@ -5,17 +5,19 @@ from __future__ import annotations
5
5
  from typing import TYPE_CHECKING
6
6
 
7
7
  if TYPE_CHECKING:
8
- from .core import Agent, Tool, Workflow
8
+ from .core import Agent, FallbackModel, ModelEntry, Tool, Workflow
9
9
 
10
10
  try:
11
11
  from ._version import __version__ # type: ignore
12
12
  except ImportError:
13
13
  __version__ = "0.0.0.dev0"
14
14
 
15
- __all__ = ["Agent", "Tool", "Workflow"]
15
+ __all__ = ["Agent", "FallbackModel", "ModelEntry", "Tool", "Workflow"]
16
16
 
17
17
  _LAZY_IMPORTS = {
18
18
  "Agent": ".core",
19
+ "FallbackModel": ".core",
20
+ "ModelEntry": ".core",
19
21
  "Tool": ".core",
20
22
  "Workflow": ".core",
21
23
  }
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.0.2'
22
- __version_tuple__ = version_tuple = (2, 0, 2)
21
+ __version__ = version = '2.0.4'
22
+ __version_tuple__ = version_tuple = (2, 0, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,84 @@
1
+ from typing import Any
2
+
3
+ # `override` was introduced in Python 3.12; use `typing_extensions` for compatibility with older versions
4
+ try:
5
+ from typing import override
6
+ except ImportError:
7
+ from typing_extensions import override
8
+
9
+ import structlog
10
+
11
+ from ...types.events.approval import ApprovalEvent as TimbalApprovalEvent
12
+ from ...types.events.base import BaseEvent as TimbalBaseEvent
13
+ from ...types.events.delta import DeltaEvent as TimbalDeltaEvent
14
+ from ...types.events.output import OutputEvent as TimbalOutputEvent
15
+ from ...types.events.start import StartEvent as TimbalStartEvent
16
+ from .. import register_collector
17
+ from ..base import BaseCollector
18
+
19
+ logger = structlog.get_logger("timbal.collectors.impl.timbal")
20
+
21
+
22
+ @register_collector
23
+ class TimbalCollector(BaseCollector):
24
+ """Collector for Timbal events."""
25
+
26
+ def __init__(self, **kwargs: Any):
27
+ super().__init__(**kwargs)
28
+ self._output_event: TimbalOutputEvent | None = None
29
+ # Capture every approval gate that fires during the stream so callers
30
+ # of .collect() can react to all pending approvals — not just the
31
+ # first one — when concurrent runnables (parallel workflow steps,
32
+ # multiplexed tools) gate on the same iteration.
33
+ self._pending_approvals: list[dict[str, Any]] = []
34
+
35
+ @classmethod
36
+ @override
37
+ def can_handle(cls, event: Any) -> bool:
38
+ return isinstance(event, TimbalBaseEvent)
39
+
40
+ @override
41
+ def process(self, event: TimbalBaseEvent) -> TimbalBaseEvent | None:
42
+ """Processes Timbal events."""
43
+ if isinstance(event, TimbalStartEvent):
44
+ return event
45
+ elif isinstance(event, TimbalDeltaEvent):
46
+ return event
47
+ elif isinstance(event, TimbalApprovalEvent):
48
+ self._pending_approvals.append({
49
+ "approval_id": event.approval_id,
50
+ "runnable_path": event.runnable_path,
51
+ "runnable_name": event.runnable_name,
52
+ "runnable_type": event.runnable_type,
53
+ "input": event.input,
54
+ "prompt": event.prompt,
55
+ "description": event.description,
56
+ "metadata": event.metadata,
57
+ "t0": event.t0,
58
+ "call_id": event.call_id,
59
+ "parent_call_id": event.parent_call_id,
60
+ })
61
+ return event
62
+ elif isinstance(event, TimbalOutputEvent):
63
+ self._output_event = event
64
+ return event
65
+ elif isinstance(event, TimbalBaseEvent):
66
+ return event
67
+ else:
68
+ logger.warning("Unknown Timbal event type", event_type=type(event), event=event)
69
+
70
+ @override
71
+ def result(self) -> Any:
72
+ """Returns the final OutputEvent enriched with pending_approvals.
73
+
74
+ When concurrent runnables gate, the OutputEvent only references the
75
+ *first* pending approval through ``status``/``output``. We attach the
76
+ full list under ``metadata['pending_approvals']`` so consumers driving
77
+ the resume loop can see every gate from one ``.collect()`` call.
78
+ """
79
+ if self._output_event is not None and self._pending_approvals:
80
+ self._output_event.metadata = {
81
+ **(self._output_event.metadata or {}),
82
+ "pending_approvals": list(self._pending_approvals),
83
+ }
84
+ return self._output_event
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
6
6
 
7
7
  if TYPE_CHECKING:
8
8
  from .agent import Agent
9
+ from .fallback_model import FallbackModel, ModelEntry
9
10
  from .mcp import MCPServer
10
11
  from .memory_compaction import MemoryCompactor # noqa: F401 - type alias
11
12
  from .skill import Skill
@@ -16,7 +17,9 @@ if TYPE_CHECKING:
16
17
 
17
18
  __all__ = [
18
19
  "Agent",
20
+ "FallbackModel",
19
21
  "MCPServer",
22
+ "ModelEntry",
20
23
  "Skill",
21
24
  "TestModel",
22
25
  "Tool",
@@ -26,7 +29,9 @@ __all__ = [
26
29
 
27
30
  _LAZY_IMPORTS = {
28
31
  "Agent": ".agent",
32
+ "FallbackModel": ".fallback_model",
29
33
  "MCPServer": ".mcp",
34
+ "ModelEntry": ".fallback_model",
30
35
  "Skill": ".skill",
31
36
  "TestModel": ".test_model",
32
37
  "Tool": ".tool",
@@ -26,7 +26,7 @@ from pydantic import (
26
26
  )
27
27
  from uuid_extensions import uuid7
28
28
 
29
- from ..errors import InterruptError, bail
29
+ from ..errors import ApprovalRequired, InterruptError, bail
30
30
  from ..state import get_run_context
31
31
  from ..types.content import CustomContent, FileContent, TextContent, ToolResultContent, ToolUseContent
32
32
  from ..types.events import BaseEvent, OutputEvent
@@ -195,12 +195,14 @@ class Agent(Runnable):
195
195
 
196
196
  # Build default params for the internal LLM tool from individual fields
197
197
  _llm_default_params = {
198
- k: v for k, v in [
198
+ k: v
199
+ for k, v in [
199
200
  ("max_tokens", self.max_tokens),
200
201
  ("temperature", self.temperature),
201
202
  ("base_url", self.base_url),
202
203
  ("api_key", self.api_key),
203
- ] if v is not None
204
+ ]
205
+ if v is not None
204
206
  }
205
207
  if self.model_params:
206
208
  _llm_default_params["provider_params"] = self.model_params
@@ -338,6 +340,37 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
338
340
 
339
341
  return system_prompt
340
342
 
343
+ def _find_pending_tool_uses(self, memory: list[Message]) -> list[ToolUseContent]:
344
+ """Return any tool_uses in the most recent assistant message that
345
+ still have no matching tool_result anywhere later in memory.
346
+
347
+ Used on approval-resume: the previous turn left tool_uses unresolved
348
+ because the user hadn't approved yet. Now that we have a decision we
349
+ re-execute those gated tool_uses directly without re-calling the LLM
350
+ (which would fail because most providers reject a request whose last
351
+ assistant message has unresolved tool_uses).
352
+ """
353
+ if not memory:
354
+ return []
355
+ for i in range(len(memory) - 1, -1, -1):
356
+ msg = memory[i]
357
+ if msg.role != "assistant":
358
+ continue
359
+ tool_uses = [
360
+ c for c in msg.content
361
+ if isinstance(c, ToolUseContent) and not c.is_server_tool_use
362
+ ]
363
+ if not tool_uses:
364
+ # Most recent assistant message has no tool_uses to resume.
365
+ return []
366
+ fulfilled: set[str] = set()
367
+ for later in memory[i + 1:]:
368
+ for c in later.content:
369
+ if isinstance(c, ToolResultContent):
370
+ fulfilled.add(c.id)
371
+ return [tu for tu in tool_uses if tu.id not in fulfilled]
372
+ return []
373
+
341
374
  def _synthesize_missing_tool_results(self, memory: list[Message]) -> None:
342
375
  """Append synthetic error results for any tool_use blocks that were interrupted.
343
376
 
@@ -435,13 +468,28 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
435
468
  return
436
469
 
437
470
  previous_span = self_spans[0]
438
-
471
+ prev_status = previous_span.status
472
+ if isinstance(prev_status, dict):
473
+ prev_code = prev_status.get("code")
474
+ prev_reason = prev_status.get("reason")
475
+ elif prev_status is not None:
476
+ prev_code = prev_status.code
477
+ prev_reason = prev_status.reason
478
+ else:
479
+ prev_code = None
480
+ prev_reason = None
439
481
  if not isinstance(previous_span.memory, list):
440
482
  return
441
483
  memory = [Message.validate(m) for m in previous_span.memory]
442
484
 
443
- # Ensure interrupted tool calls have corresponding results before resuming.
444
- self._synthesize_missing_tool_results(memory)
485
+ # On approval-required resume the gated tool_uses will be re-executed
486
+ # by the agent loop (see _find_pending_tool_uses), so we must NOT
487
+ # inject synthetic "tool failed" results for them. Without this guard
488
+ # the LLM would see fake failures and probably skip retrying the
489
+ # gated calls, silently dropping the user's approval decisions.
490
+ is_approval_resume = prev_code == "cancelled" and prev_reason == "approval_required"
491
+ if not is_approval_resume:
492
+ self._synthesize_missing_tool_results(memory)
445
493
  current_span.memory = memory + current_span.memory
446
494
 
447
495
  # Cache the already-serialized previous memory so handler() can skip re-dumping.
@@ -472,9 +520,7 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
472
520
  )
473
521
  should_compact = True
474
522
  elif previous_span.usage:
475
- prev_input_tokens = sum(
476
- v for k, v in previous_span.usage.items() if ":input" in k and "token" in k
477
- )
523
+ prev_input_tokens = sum(v for k, v in previous_span.usage.items() if ":input" in k and "token" in k)
478
524
  prev_output_tokens = sum(
479
525
  v for k, v in previous_span.usage.items() if ":output" in k and "token" in k
480
526
  )
@@ -495,9 +541,7 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
495
541
 
496
542
  if should_compact:
497
543
  compactors = (
498
- [self.memory_compaction]
499
- if not isinstance(self.memory_compaction, list)
500
- else self.memory_compaction
544
+ [self.memory_compaction] if not isinstance(self.memory_compaction, list) else self.memory_compaction
501
545
  )
502
546
  compaction_steps = []
503
547
  for compactor in compactors:
@@ -509,11 +553,13 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
509
553
  current_span.memory = await compactor(current_span.memory)
510
554
  else:
511
555
  current_span.memory = compactor(current_span.memory)
512
- compaction_steps.append({
513
- "compactor": getattr(compactor, "__name__", repr(compactor)),
514
- "before": before,
515
- "after": len(current_span.memory),
516
- })
556
+ compaction_steps.append(
557
+ {
558
+ "compactor": getattr(compactor, "__name__", repr(compactor)),
559
+ "before": before,
560
+ "after": len(current_span.memory),
561
+ }
562
+ )
517
563
  current_span.metadata["compaction"] = {
518
564
  "triggered": True,
519
565
  "utilization": round(utilization, 4) if utilization is not None else None,
@@ -537,6 +583,9 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
537
583
  tools_names.add(tool.name)
538
584
  if tool.command:
539
585
  commands[tool.command] = tool
586
+ stripped = tool.command.strip("/")
587
+ if stripped:
588
+ commands[stripped] = tool
540
589
 
541
590
  for t in self.tools:
542
591
  if isinstance(t, ToolSet):
@@ -632,7 +681,7 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
632
681
  # Reuse the already-serialized previous messages; only dump new ones
633
682
  # (prompt + any synthetic tool results added by _synthesize_missing_tool_results).
634
683
  # If compaction ran it rewrites memory, invalidating the cached dump.
635
- new_messages = current_span.memory[len(prev_dump):]
684
+ new_messages = current_span.memory[len(prev_dump) :]
636
685
  current_span._memory_dump = prev_dump + await dump(new_messages)
637
686
  else:
638
687
  current_span._memory_dump = await dump(current_span.memory)
@@ -649,10 +698,15 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
649
698
  """Helper to process tool output events and create tool results."""
650
699
  if not isinstance(event, OutputEvent) or event.path.count(".") != self._path.count(".") + 1:
651
700
  return
701
+ if event.status.code == "cancelled" and event.status.reason == "approval_required":
702
+ return
652
703
  if event.status.code == "cancelled" and event.status.reason == "early_exit":
653
704
  bail(event.status.message)
654
705
  content = None
655
- if event.status.code == "cancelled" and event.status.reason == "early_exit_local":
706
+ if event.status.code == "cancelled" and event.status.reason == "approval_denied":
707
+ msg = event.status.message or "The tool call was denied."
708
+ content = f"[Approval denied] {msg}"
709
+ elif event.status.code == "cancelled" and event.status.reason == "early_exit_local":
656
710
  msg = event.status.message or "The tool exited early."
657
711
  content = f"[Cancelled] {msg}"
658
712
  elif event.error is not None:
@@ -713,7 +767,12 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
713
767
  {
714
768
  "role": "assistant",
715
769
  "content": [
716
- {"type": "tool_use", "id": tool_use_id, "name": tool.name, "input": tool_input}
770
+ {
771
+ "type": "tool_use",
772
+ "id": tool_use_id,
773
+ "name": tool.name,
774
+ "input": tool_input,
775
+ }
717
776
  ],
718
777
  }
719
778
  )
@@ -721,6 +780,12 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
721
780
  async for event in tool(**tool_input):
722
781
  await _process_tool_event(event, tool_use_id, append_to_messages=False)
723
782
  if isinstance(event, OutputEvent) and event.output is not None:
783
+ if (
784
+ event.status.code == "cancelled"
785
+ and event.status.reason == "approval_required"
786
+ ):
787
+ yield event
788
+ raise ApprovalRequired(event)
724
789
  current_span.memory.append(
725
790
  Message.validate(
726
791
  {
@@ -732,6 +797,32 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
732
797
  yield event
733
798
  return
734
799
 
800
+ # Resume path: if the trailing assistant message has tool_uses
801
+ # that were left unresolved by an earlier approval gate, run
802
+ # them directly. Skipping the LLM call here is important —
803
+ # most providers reject a request whose conversation ends in
804
+ # an assistant message whose tool_uses have no matching
805
+ # tool_results.
806
+ pending_tool_uses = self._find_pending_tool_uses(current_span.memory)
807
+ if pending_tool_uses:
808
+ _llm_memory_saved = True # nothing to salvage; we never called the LLM
809
+ tool_calls = pending_tool_uses
810
+ first_pending_approval: OutputEvent | None = None
811
+ async for tool_call, event in self._multiplex_tools(tools, tool_calls):
812
+ await _process_tool_event(event, tool_call.id, append_to_messages=True)
813
+ yield event
814
+ if (
815
+ isinstance(event, OutputEvent)
816
+ and event.status.code == "cancelled"
817
+ and event.status.reason == "approval_required"
818
+ and first_pending_approval is None
819
+ ):
820
+ first_pending_approval = event
821
+ if first_pending_approval is not None:
822
+ raise ApprovalRequired(first_pending_approval)
823
+ i += 1
824
+ continue
825
+
735
826
  async for event in self._llm(
736
827
  model=model,
737
828
  messages=current_span.memory,
@@ -819,9 +910,19 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
819
910
  if not tool_calls:
820
911
  break
821
912
 
913
+ first_pending_approval: OutputEvent | None = None
822
914
  async for tool_call, event in self._multiplex_tools(tools, tool_calls):
823
915
  await _process_tool_event(event, tool_call.id, append_to_messages=True)
824
916
  yield event
917
+ if (
918
+ isinstance(event, OutputEvent)
919
+ and event.status.code == "cancelled"
920
+ and event.status.reason == "approval_required"
921
+ and first_pending_approval is None
922
+ ):
923
+ first_pending_approval = event
924
+ if first_pending_approval is not None:
925
+ raise ApprovalRequired(first_pending_approval)
825
926
  i += 1
826
927
  finally:
827
928
  if not _llm_memory_saved:
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator, Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ import structlog
8
+ from anthropic import APIConnectionError as AnthropicAPIConnectionError
9
+ from anthropic import APIStatusError as AnthropicAPIStatusError
10
+ from anthropic import APITimeoutError as AnthropicAPITimeoutError
11
+ from anthropic import RateLimitError as AnthropicRateLimitError
12
+ from openai import APIConnectionError as OpenAIAPIConnectionError
13
+ from openai import APIStatusError as OpenAIAPIStatusError
14
+ from openai import APITimeoutError as OpenAIAPITimeoutError
15
+ from openai import RateLimitError as OpenAIRateLimitError
16
+
17
+ from ..errors import FallbackExhausted
18
+
19
+ logger = structlog.get_logger("timbal.core.fallback_model")
20
+
21
+ _RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ModelEntry:
26
+ """One model in a fallback chain."""
27
+
28
+ model: str
29
+ max_retries: int = 2
30
+ retry_delay: float = 1.0
31
+ api_key: str | None = None
32
+ base_url: str | None = None
33
+
34
+
35
+ class FallbackModel:
36
+ """Ordered fallback chain for LLM providers.
37
+
38
+ The first model is tried first. If it fails with a retryable provider error
39
+ after its per-model retries are exhausted, the next entry is attempted.
40
+ """
41
+
42
+ __timbal_fallback_model__ = True
43
+ provider = "fallback"
44
+
45
+ def __init__(
46
+ self,
47
+ *models: str | ModelEntry,
48
+ fallback_on: type[BaseException]
49
+ | tuple[type[BaseException], ...]
50
+ | list[type[BaseException]]
51
+ | Callable[[BaseException], bool]
52
+ | None = None,
53
+ ) -> None:
54
+ if not models:
55
+ raise ValueError("FallbackModel requires at least one model.")
56
+
57
+ self.entries = tuple(entry if isinstance(entry, ModelEntry) else ModelEntry(entry) for entry in models)
58
+ self.fallback_on = fallback_on
59
+ self.model_name = " -> ".join(entry.model for entry in self.entries)
60
+
61
+ def __str__(self) -> str:
62
+ return self.entries[0].model
63
+
64
+ async def route(
65
+ self,
66
+ router: Callable[..., AsyncGenerator[Any, None]],
67
+ **llm_router_kwargs: Any,
68
+ ) -> AsyncGenerator[Any, None]:
69
+ errors: list[tuple[str, BaseException]] = []
70
+
71
+ for index, entry in enumerate(self.entries):
72
+ started = False
73
+ kwargs = {
74
+ **llm_router_kwargs,
75
+ "model": entry.model,
76
+ "max_retries": entry.max_retries,
77
+ "retry_delay": entry.retry_delay,
78
+ }
79
+ if entry.api_key is not None:
80
+ kwargs["api_key"] = entry.api_key
81
+ if entry.base_url is not None:
82
+ kwargs["base_url"] = entry.base_url
83
+
84
+ try:
85
+ async for chunk in router(**kwargs):
86
+ started = True
87
+ yield chunk
88
+ return
89
+ except Exception as exc:
90
+ if started:
91
+ raise
92
+ if not self._should_fallback(exc):
93
+ raise
94
+
95
+ errors.append((entry.model, exc))
96
+ next_model = self.entries[index + 1].model if index + 1 < len(self.entries) else None
97
+ logger.warning(
98
+ "Falling back to next LLM model",
99
+ failed_model=entry.model,
100
+ next_model=next_model,
101
+ error_type=type(exc).__name__,
102
+ error=str(exc),
103
+ )
104
+
105
+ raise FallbackExhausted(errors)
106
+
107
+ def _should_fallback(self, exc: BaseException) -> bool:
108
+ if self.fallback_on is None:
109
+ return is_retryable_provider_error(exc)
110
+ if isinstance(self.fallback_on, type) and issubclass(self.fallback_on, BaseException):
111
+ return isinstance(exc, self.fallback_on)
112
+ if isinstance(self.fallback_on, (tuple, list)):
113
+ return isinstance(exc, self.fallback_on)
114
+ return bool(self.fallback_on(exc))
115
+
116
+
117
+ def is_retryable_provider_error(exc: BaseException) -> bool:
118
+ if isinstance(exc, (OpenAIRateLimitError, AnthropicRateLimitError)):
119
+ return True
120
+ if isinstance(exc, (OpenAIAPITimeoutError, AnthropicAPITimeoutError)):
121
+ return True
122
+ if isinstance(exc, (OpenAIAPIConnectionError, AnthropicAPIConnectionError)):
123
+ return True
124
+ if isinstance(exc, (OpenAIAPIStatusError, AnthropicAPIStatusError)):
125
+ status_code = getattr(exc, "status_code", None)
126
+ if status_code is None:
127
+ status_code = getattr(getattr(exc, "response", None), "status_code", None)
128
+ return status_code in _RETRYABLE_STATUS_CODES
129
+ if isinstance(exc, StopAsyncIteration):
130
+ return True
131
+
132
+ message = str(exc).lower()
133
+ return "overload" in message or "capacity" in message