prompture 0.0.47.dev1__tar.gz → 0.0.47.dev2__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 (139) hide show
  1. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/PKG-INFO +35 -2
  2. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/README.md +34 -1
  3. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/_version.py +2 -2
  4. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/async_conversation.py +71 -2
  5. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/conversation.py +71 -2
  6. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_moonshot_driver.py +12 -3
  7. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/moonshot_driver.py +12 -3
  8. prompture-0.0.47.dev2/prompture/simulated_tools.py +115 -0
  9. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/tools_schema.py +22 -0
  10. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture.egg-info/PKG-INFO +35 -2
  11. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture.egg-info/SOURCES.txt +1 -0
  12. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.claude/skills/add-driver/SKILL.md +0 -0
  13. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.claude/skills/add-driver/references/driver-template.md +0 -0
  14. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.claude/skills/add-example/SKILL.md +0 -0
  15. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.claude/skills/add-field/SKILL.md +0 -0
  16. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.claude/skills/add-persona/SKILL.md +0 -0
  17. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.claude/skills/add-test/SKILL.md +0 -0
  18. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.claude/skills/add-tool/SKILL.md +0 -0
  19. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.claude/skills/run-tests/SKILL.md +0 -0
  20. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.claude/skills/scaffold-extraction/SKILL.md +0 -0
  21. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.claude/skills/update-pricing/SKILL.md +0 -0
  22. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.env.copy +0 -0
  23. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.github/FUNDING.yml +0 -0
  24. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.github/scripts/update_docs_version.py +0 -0
  25. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.github/scripts/update_wrapper_version.py +0 -0
  26. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.github/workflows/dev.yml +0 -0
  27. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.github/workflows/documentation.yml +0 -0
  28. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/.github/workflows/publish.yml +0 -0
  29. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/CLAUDE.md +0 -0
  30. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/LICENSE +0 -0
  31. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/MANIFEST.in +0 -0
  32. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/ROADMAP.md +0 -0
  33. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/_static/custom.css +0 -0
  34. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/_templates/footer.html +0 -0
  35. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/api/core.rst +0 -0
  36. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/api/drivers.rst +0 -0
  37. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/api/field_definitions.rst +0 -0
  38. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/api/index.rst +0 -0
  39. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/api/runner.rst +0 -0
  40. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/api/tools.rst +0 -0
  41. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/api/validator.rst +0 -0
  42. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/conf.py +0 -0
  43. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/contributing.rst +0 -0
  44. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/examples.rst +0 -0
  45. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/field_definitions_reference.rst +0 -0
  46. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/index.rst +0 -0
  47. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/installation.rst +0 -0
  48. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/quickstart.rst +0 -0
  49. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/docs/source/toon_input_guide.rst +0 -0
  50. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/packages/README.md +0 -0
  51. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/packages/llm_to_json/README.md +0 -0
  52. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/packages/llm_to_json/llm_to_json/__init__.py +0 -0
  53. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/packages/llm_to_json/pyproject.toml +0 -0
  54. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/packages/llm_to_json/test.py +0 -0
  55. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/packages/llm_to_toon/README.md +0 -0
  56. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/packages/llm_to_toon/llm_to_toon/__init__.py +0 -0
  57. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/packages/llm_to_toon/pyproject.toml +0 -0
  58. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/packages/llm_to_toon/test.py +0 -0
  59. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/__init__.py +0 -0
  60. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/agent.py +0 -0
  61. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/agent_types.py +0 -0
  62. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/aio/__init__.py +0 -0
  63. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/async_agent.py +0 -0
  64. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/async_core.py +0 -0
  65. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/async_driver.py +0 -0
  66. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/async_groups.py +0 -0
  67. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/cache.py +0 -0
  68. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/callbacks.py +0 -0
  69. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/cli.py +0 -0
  70. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/core.py +0 -0
  71. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/cost_mixin.py +0 -0
  72. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/discovery.py +0 -0
  73. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/driver.py +0 -0
  74. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/__init__.py +0 -0
  75. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/airllm_driver.py +0 -0
  76. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_airllm_driver.py +0 -0
  77. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_azure_driver.py +0 -0
  78. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_claude_driver.py +0 -0
  79. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_google_driver.py +0 -0
  80. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_grok_driver.py +0 -0
  81. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_groq_driver.py +0 -0
  82. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_hugging_driver.py +0 -0
  83. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_lmstudio_driver.py +0 -0
  84. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_local_http_driver.py +0 -0
  85. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_modelscope_driver.py +0 -0
  86. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_ollama_driver.py +0 -0
  87. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_openai_driver.py +0 -0
  88. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_openrouter_driver.py +0 -0
  89. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_registry.py +0 -0
  90. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/async_zai_driver.py +0 -0
  91. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/azure_driver.py +0 -0
  92. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/claude_driver.py +0 -0
  93. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/google_driver.py +0 -0
  94. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/grok_driver.py +0 -0
  95. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/groq_driver.py +0 -0
  96. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/hugging_driver.py +0 -0
  97. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/lmstudio_driver.py +0 -0
  98. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/local_http_driver.py +0 -0
  99. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/modelscope_driver.py +0 -0
  100. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/ollama_driver.py +0 -0
  101. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/openai_driver.py +0 -0
  102. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/openrouter_driver.py +0 -0
  103. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/registry.py +0 -0
  104. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/vision_helpers.py +0 -0
  105. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/drivers/zai_driver.py +0 -0
  106. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/field_definitions.py +0 -0
  107. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/group_types.py +0 -0
  108. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/groups.py +0 -0
  109. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/image.py +0 -0
  110. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/ledger.py +0 -0
  111. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/logging.py +0 -0
  112. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/model_rates.py +0 -0
  113. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/persistence.py +0 -0
  114. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/persona.py +0 -0
  115. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/runner.py +0 -0
  116. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/scaffold/__init__.py +0 -0
  117. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/scaffold/generator.py +0 -0
  118. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/scaffold/templates/Dockerfile.j2 +0 -0
  119. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/scaffold/templates/README.md.j2 +0 -0
  120. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/scaffold/templates/config.py.j2 +0 -0
  121. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/scaffold/templates/env.example.j2 +0 -0
  122. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/scaffold/templates/main.py.j2 +0 -0
  123. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/scaffold/templates/models.py.j2 +0 -0
  124. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/scaffold/templates/requirements.txt.j2 +0 -0
  125. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/serialization.py +0 -0
  126. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/server.py +0 -0
  127. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/session.py +0 -0
  128. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/settings.py +0 -0
  129. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/tools.py +0 -0
  130. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture/validator.py +0 -0
  131. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture.egg-info/dependency_links.txt +0 -0
  132. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture.egg-info/entry_points.txt +0 -0
  133. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture.egg-info/requires.txt +0 -0
  134. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/prompture.egg-info/top_level.txt +0 -0
  135. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/pyproject.toml +0 -0
  136. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/requirements.txt +0 -0
  137. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/setup.cfg +0 -0
  138. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/test.py +0 -0
  139. {prompture-0.0.47.dev1 → prompture-0.0.47.dev2}/test_version_diagnosis.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prompture
3
- Version: 0.0.47.dev1
3
+ Version: 0.0.47.dev2
4
4
  Summary: Ask LLMs to return structured JSON and run cross-model tests. API-first.
5
5
  Author-email: Juan Denis <juan@vene.co>
6
6
  License-Expression: MIT
@@ -83,7 +83,7 @@ print(person.name) # Maria
83
83
  - **Stepwise extraction** — Per-field prompts with smart type coercion (shorthand numbers, multilingual booleans, dates)
84
84
  - **Field registry** — 50+ predefined extraction fields with template variables and Pydantic integration
85
85
  - **Conversations** — Stateful multi-turn sessions with sync and async support
86
- - **Tool use** — Function calling and streaming across supported providers
86
+ - **Tool use** — Function calling and streaming across supported providers, with automatic prompt-based simulation for models without native tool support
87
87
  - **Caching** — Built-in response cache with memory, SQLite, and Redis backends
88
88
  - **Plugin system** — Register custom drivers via entry points
89
89
  - **Usage tracking** — Token counts and cost calculation on every call
@@ -296,6 +296,39 @@ response = conv.send("What is the capital of France?")
296
296
  follow_up = conv.send("What about Germany?") # retains context
297
297
  ```
298
298
 
299
+ ### Tool Use
300
+
301
+ Register Python functions as tools the LLM can call during a conversation:
302
+
303
+ ```python
304
+ from prompture import Conversation, ToolRegistry
305
+
306
+ registry = ToolRegistry()
307
+
308
+ @registry.tool
309
+ def get_weather(city: str, units: str = "celsius") -> str:
310
+ """Get the current weather for a city."""
311
+ return f"Weather in {city}: 22 {units}"
312
+
313
+ conv = Conversation("openai/gpt-4", tools=registry)
314
+ result = conv.ask("What's the weather in London?")
315
+ ```
316
+
317
+ For models without native function calling (Ollama, LM Studio, etc.), Prompture automatically simulates tool use by describing tools in the prompt and parsing structured JSON responses:
318
+
319
+ ```python
320
+ # Auto-detect: uses native tool calling if available, simulation otherwise
321
+ conv = Conversation("ollama/llama3.1:8b", tools=registry, simulated_tools="auto")
322
+
323
+ # Force simulation even on capable models
324
+ conv = Conversation("openai/gpt-4", tools=registry, simulated_tools=True)
325
+
326
+ # Disable tool use entirely
327
+ conv = Conversation("openai/gpt-4", tools=registry, simulated_tools=False)
328
+ ```
329
+
330
+ The simulation loop describes tools in the system prompt, asks the model to respond with JSON (`tool_call` or `final_answer`), executes tools, and feeds results back — all transparent to the caller.
331
+
299
332
  ### Model Discovery
300
333
 
301
334
  Auto-detect available models from configured providers:
@@ -36,7 +36,7 @@ print(person.name) # Maria
36
36
  - **Stepwise extraction** — Per-field prompts with smart type coercion (shorthand numbers, multilingual booleans, dates)
37
37
  - **Field registry** — 50+ predefined extraction fields with template variables and Pydantic integration
38
38
  - **Conversations** — Stateful multi-turn sessions with sync and async support
39
- - **Tool use** — Function calling and streaming across supported providers
39
+ - **Tool use** — Function calling and streaming across supported providers, with automatic prompt-based simulation for models without native tool support
40
40
  - **Caching** — Built-in response cache with memory, SQLite, and Redis backends
41
41
  - **Plugin system** — Register custom drivers via entry points
42
42
  - **Usage tracking** — Token counts and cost calculation on every call
@@ -249,6 +249,39 @@ response = conv.send("What is the capital of France?")
249
249
  follow_up = conv.send("What about Germany?") # retains context
250
250
  ```
251
251
 
252
+ ### Tool Use
253
+
254
+ Register Python functions as tools the LLM can call during a conversation:
255
+
256
+ ```python
257
+ from prompture import Conversation, ToolRegistry
258
+
259
+ registry = ToolRegistry()
260
+
261
+ @registry.tool
262
+ def get_weather(city: str, units: str = "celsius") -> str:
263
+ """Get the current weather for a city."""
264
+ return f"Weather in {city}: 22 {units}"
265
+
266
+ conv = Conversation("openai/gpt-4", tools=registry)
267
+ result = conv.ask("What's the weather in London?")
268
+ ```
269
+
270
+ For models without native function calling (Ollama, LM Studio, etc.), Prompture automatically simulates tool use by describing tools in the prompt and parsing structured JSON responses:
271
+
272
+ ```python
273
+ # Auto-detect: uses native tool calling if available, simulation otherwise
274
+ conv = Conversation("ollama/llama3.1:8b", tools=registry, simulated_tools="auto")
275
+
276
+ # Force simulation even on capable models
277
+ conv = Conversation("openai/gpt-4", tools=registry, simulated_tools=True)
278
+
279
+ # Disable tool use entirely
280
+ conv = Conversation("openai/gpt-4", tools=registry, simulated_tools=False)
281
+ ```
282
+
283
+ The simulation loop describes tools in the system prompt, asks the model to respond with JSON (`tool_call` or `final_answer`), executes tools, and feeds results back — all transparent to the caller.
284
+
252
285
  ### Model Discovery
253
286
 
254
287
  Auto-detect available models from configured providers:
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.0.47.dev1'
32
- __version_tuple__ = version_tuple = (0, 0, 47, 'dev1')
31
+ __version__ = version = '0.0.47.dev2'
32
+ __version_tuple__ = version_tuple = (0, 0, 47, 'dev2')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -55,6 +55,7 @@ class AsyncConversation:
55
55
  callbacks: DriverCallbacks | None = None,
56
56
  tools: ToolRegistry | None = None,
57
57
  max_tool_rounds: int = 10,
58
+ simulated_tools: bool | Literal["auto"] = "auto",
58
59
  conversation_id: str | None = None,
59
60
  auto_save: str | Path | None = None,
60
61
  tags: list[str] | None = None,
@@ -106,6 +107,7 @@ class AsyncConversation:
106
107
  }
107
108
  self._tools = tools or ToolRegistry()
108
109
  self._max_tool_rounds = max_tool_rounds
110
+ self._simulated_tools = simulated_tools
109
111
 
110
112
  # Persistence
111
113
  self._conversation_id = conversation_id or str(uuid.uuid4())
@@ -324,8 +326,13 @@ class AsyncConversation:
324
326
  If tools are registered and the driver supports tool use,
325
327
  dispatches to the async tool execution loop.
326
328
  """
327
- if self._tools and getattr(self._driver, "supports_tool_use", False):
328
- return await self._ask_with_tools(content, options, images=images)
329
+ # Route to appropriate tool handling
330
+ if self._tools:
331
+ use_native = getattr(self._driver, "supports_tool_use", False)
332
+ if self._simulated_tools is True or (self._simulated_tools == "auto" and not use_native):
333
+ return await self._ask_with_simulated_tools(content, options, images=images)
334
+ elif use_native and self._simulated_tools is not True:
335
+ return await self._ask_with_tools(content, options, images=images)
329
336
 
330
337
  merged = {**self._options, **(options or {})}
331
338
  messages = self._build_messages(content, images=images)
@@ -377,6 +384,11 @@ class AsyncConversation:
377
384
  }
378
385
  for tc in tool_calls
379
386
  ]
387
+ # Preserve reasoning_content for providers that require it
388
+ # on subsequent requests (e.g. Moonshot reasoning models).
389
+ if resp.get("reasoning_content") is not None:
390
+ assistant_msg["reasoning_content"] = resp["reasoning_content"]
391
+
380
392
  self._messages.append(assistant_msg)
381
393
  msgs.append(assistant_msg)
382
394
 
@@ -397,6 +409,63 @@ class AsyncConversation:
397
409
 
398
410
  raise RuntimeError(f"Tool execution loop exceeded {self._max_tool_rounds} rounds")
399
411
 
412
+ async def _ask_with_simulated_tools(
413
+ self,
414
+ content: str,
415
+ options: dict[str, Any] | None = None,
416
+ images: list[ImageInput] | None = None,
417
+ ) -> str:
418
+ """Async prompt-based tool calling for drivers without native tool use."""
419
+ from .simulated_tools import build_tool_prompt, format_tool_result, parse_simulated_response
420
+
421
+ merged = {**self._options, **(options or {})}
422
+ tool_prompt = build_tool_prompt(self._tools)
423
+
424
+ # Augment system prompt with tool descriptions
425
+ augmented_system = tool_prompt
426
+ if self._system_prompt:
427
+ augmented_system = f"{self._system_prompt}\n\n{tool_prompt}"
428
+
429
+ # Record user message in history
430
+ user_content = self._build_content_with_images(content, images)
431
+ self._messages.append({"role": "user", "content": user_content})
432
+
433
+ for _round in range(self._max_tool_rounds):
434
+ # Build messages with the augmented system prompt
435
+ msgs: list[dict[str, Any]] = []
436
+ msgs.append({"role": "system", "content": augmented_system})
437
+ msgs.extend(self._messages)
438
+
439
+ resp = await self._driver.generate_messages_with_hooks(msgs, merged)
440
+ text = resp.get("text", "")
441
+ meta = resp.get("meta", {})
442
+ self._accumulate_usage(meta)
443
+
444
+ parsed = parse_simulated_response(text, self._tools)
445
+
446
+ if parsed["type"] == "final_answer":
447
+ answer = parsed["content"]
448
+ self._messages.append({"role": "assistant", "content": answer})
449
+ return answer
450
+
451
+ # Tool call
452
+ tool_name = parsed["name"]
453
+ tool_args = parsed["arguments"]
454
+
455
+ # Record assistant's tool call as an assistant message
456
+ self._messages.append({"role": "assistant", "content": text})
457
+
458
+ try:
459
+ result = self._tools.execute(tool_name, tool_args)
460
+ result_msg = format_tool_result(tool_name, result)
461
+ except Exception as exc:
462
+ result_msg = format_tool_result(tool_name, f"Error: {exc}")
463
+
464
+ # Record tool result as a user message
465
+ self._messages.append({"role": "user", "content": result_msg})
466
+
467
+ raise RuntimeError(f"Simulated tool execution loop exceeded {self._max_tool_rounds} rounds")
468
+
400
469
  def _build_messages_raw(self) -> list[dict[str, Any]]:
401
470
  """Build messages array from system prompt + full history (including tool messages)."""
402
471
  msgs: list[dict[str, Any]] = []
@@ -56,6 +56,7 @@ class Conversation:
56
56
  callbacks: DriverCallbacks | None = None,
57
57
  tools: ToolRegistry | None = None,
58
58
  max_tool_rounds: int = 10,
59
+ simulated_tools: bool | Literal["auto"] = "auto",
59
60
  conversation_id: str | None = None,
60
61
  auto_save: str | Path | None = None,
61
62
  tags: list[str] | None = None,
@@ -109,6 +110,7 @@ class Conversation:
109
110
  }
110
111
  self._tools = tools or ToolRegistry()
111
112
  self._max_tool_rounds = max_tool_rounds
113
+ self._simulated_tools = simulated_tools
112
114
 
113
115
  # Persistence
114
116
  self._conversation_id = conversation_id or str(uuid.uuid4())
@@ -338,8 +340,13 @@ class Conversation:
338
340
  images: Optional list of images to include (bytes, path, URL,
339
341
  base64 string, or :class:`ImageContent`).
340
342
  """
341
- if self._tools and getattr(self._driver, "supports_tool_use", False):
342
- return self._ask_with_tools(content, options, images=images)
343
+ # Route to appropriate tool handling
344
+ if self._tools:
345
+ use_native = getattr(self._driver, "supports_tool_use", False)
346
+ if self._simulated_tools is True or (self._simulated_tools == "auto" and not use_native):
347
+ return self._ask_with_simulated_tools(content, options, images=images)
348
+ elif use_native and self._simulated_tools is not True:
349
+ return self._ask_with_tools(content, options, images=images)
343
350
 
344
351
  merged = {**self._options, **(options or {})}
345
352
  messages = self._build_messages(content, images=images)
@@ -395,6 +402,11 @@ class Conversation:
395
402
  }
396
403
  for tc in tool_calls
397
404
  ]
405
+ # Preserve reasoning_content for providers that require it
406
+ # on subsequent requests (e.g. Moonshot reasoning models).
407
+ if resp.get("reasoning_content") is not None:
408
+ assistant_msg["reasoning_content"] = resp["reasoning_content"]
409
+
398
410
  self._messages.append(assistant_msg)
399
411
  msgs.append(assistant_msg)
400
412
 
@@ -416,6 +428,63 @@ class Conversation:
416
428
 
417
429
  raise RuntimeError(f"Tool execution loop exceeded {self._max_tool_rounds} rounds")
418
430
 
431
+ def _ask_with_simulated_tools(
432
+ self,
433
+ content: str,
434
+ options: dict[str, Any] | None = None,
435
+ images: list[ImageInput] | None = None,
436
+ ) -> str:
437
+ """Prompt-based tool calling for drivers without native tool use."""
438
+ from .simulated_tools import build_tool_prompt, format_tool_result, parse_simulated_response
439
+
440
+ merged = {**self._options, **(options or {})}
441
+ tool_prompt = build_tool_prompt(self._tools)
442
+
443
+ # Augment system prompt with tool descriptions
444
+ augmented_system = tool_prompt
445
+ if self._system_prompt:
446
+ augmented_system = f"{self._system_prompt}\n\n{tool_prompt}"
447
+
448
+ # Record user message in history
449
+ user_content = self._build_content_with_images(content, images)
450
+ self._messages.append({"role": "user", "content": user_content})
451
+
452
+ for _round in range(self._max_tool_rounds):
453
+ # Build messages with the augmented system prompt
454
+ msgs: list[dict[str, Any]] = []
455
+ msgs.append({"role": "system", "content": augmented_system})
456
+ msgs.extend(self._messages)
457
+
458
+ resp = self._driver.generate_messages_with_hooks(msgs, merged)
459
+ text = resp.get("text", "")
460
+ meta = resp.get("meta", {})
461
+ self._accumulate_usage(meta)
462
+
463
+ parsed = parse_simulated_response(text, self._tools)
464
+
465
+ if parsed["type"] == "final_answer":
466
+ answer = parsed["content"]
467
+ self._messages.append({"role": "assistant", "content": answer})
468
+ return answer
469
+
470
+ # Tool call
471
+ tool_name = parsed["name"]
472
+ tool_args = parsed["arguments"]
473
+
474
+ # Record assistant's tool call as an assistant message
475
+ self._messages.append({"role": "assistant", "content": text})
476
+
477
+ try:
478
+ result = self._tools.execute(tool_name, tool_args)
479
+ result_msg = format_tool_result(tool_name, result)
480
+ except Exception as exc:
481
+ result_msg = format_tool_result(tool_name, f"Error: {exc}")
482
+
483
+ # Record tool result as a user message (all drivers understand user/assistant)
484
+ self._messages.append({"role": "user", "content": result_msg})
485
+
486
+ raise RuntimeError(f"Simulated tool execution loop exceeded {self._max_tool_rounds} rounds")
487
+
419
488
  def _build_messages_raw(self) -> list[dict[str, Any]]:
420
489
  """Build messages array from system prompt + full history (including tool messages)."""
421
490
  msgs: list[dict[str, Any]] = []
@@ -271,11 +271,12 @@ class AsyncMoonshotDriver(CostMixin, AsyncDriver):
271
271
  }
272
272
 
273
273
  choice = resp["choices"][0]
274
- text = choice["message"].get("content") or ""
274
+ message = choice["message"]
275
+ text = message.get("content") or ""
275
276
  stop_reason = choice.get("finish_reason")
276
277
 
277
278
  tool_calls_out: list[dict[str, Any]] = []
278
- for tc in choice["message"].get("tool_calls", []):
279
+ for tc in message.get("tool_calls", []):
279
280
  try:
280
281
  args = json.loads(tc["function"]["arguments"])
281
282
  except (json.JSONDecodeError, TypeError):
@@ -288,13 +289,21 @@ class AsyncMoonshotDriver(CostMixin, AsyncDriver):
288
289
  }
289
290
  )
290
291
 
291
- return {
292
+ result: dict[str, Any] = {
292
293
  "text": text,
293
294
  "meta": meta,
294
295
  "tool_calls": tool_calls_out,
295
296
  "stop_reason": stop_reason,
296
297
  }
297
298
 
299
+ # Preserve reasoning_content for reasoning models so the
300
+ # conversation loop can include it when sending the assistant
301
+ # message back (Moonshot requires it on subsequent requests).
302
+ if message.get("reasoning_content") is not None:
303
+ result["reasoning_content"] = message["reasoning_content"]
304
+
305
+ return result
306
+
298
307
  # ------------------------------------------------------------------
299
308
  # Streaming
300
309
  # ------------------------------------------------------------------
@@ -364,11 +364,12 @@ class MoonshotDriver(CostMixin, Driver):
364
364
  }
365
365
 
366
366
  choice = resp["choices"][0]
367
- text = choice["message"].get("content") or ""
367
+ message = choice["message"]
368
+ text = message.get("content") or ""
368
369
  stop_reason = choice.get("finish_reason")
369
370
 
370
371
  tool_calls_out: list[dict[str, Any]] = []
371
- for tc in choice["message"].get("tool_calls", []):
372
+ for tc in message.get("tool_calls", []):
372
373
  try:
373
374
  args = json.loads(tc["function"]["arguments"])
374
375
  except (json.JSONDecodeError, TypeError):
@@ -381,13 +382,21 @@ class MoonshotDriver(CostMixin, Driver):
381
382
  }
382
383
  )
383
384
 
384
- return {
385
+ result: dict[str, Any] = {
385
386
  "text": text,
386
387
  "meta": meta,
387
388
  "tool_calls": tool_calls_out,
388
389
  "stop_reason": stop_reason,
389
390
  }
390
391
 
392
+ # Preserve reasoning_content for reasoning models so the
393
+ # conversation loop can include it when sending the assistant
394
+ # message back (Moonshot requires it on subsequent requests).
395
+ if message.get("reasoning_content") is not None:
396
+ result["reasoning_content"] = message["reasoning_content"]
397
+
398
+ return result
399
+
391
400
  # ------------------------------------------------------------------
392
401
  # Streaming
393
402
  # ------------------------------------------------------------------
@@ -0,0 +1,115 @@
1
+ """Prompt-based tool calling for drivers without native tool use support.
2
+
3
+ When a driver lacks ``supports_tool_use`` the conversation classes can
4
+ fall back to *simulated* tool calling: the available tools are described
5
+ in the system prompt, the model is asked to respond with a structured
6
+ JSON object (either a tool call or a final answer), and Prompture
7
+ parses + dispatches accordingly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ from typing import Any
15
+
16
+ from .tools import clean_json_text
17
+ from .tools_schema import ToolRegistry
18
+
19
+ logger = logging.getLogger("prompture.simulated_tools")
20
+
21
+
22
+ def build_tool_prompt(tools: ToolRegistry) -> str:
23
+ """Build a plain-text prompt section describing all registered tools.
24
+
25
+ The returned string should be appended to the system prompt so the
26
+ model knows which tools are available and how to call them.
27
+ """
28
+ lines = [
29
+ "You have access to the following tools:",
30
+ "",
31
+ tools.to_prompt_format(),
32
+ "",
33
+ "To use a tool, respond with ONLY a JSON object in this exact format:",
34
+ '{"type": "tool_call", "name": "<tool_name>", "arguments": {<args>}}',
35
+ "",
36
+ "When you have the final answer (after using tools or if no tool is needed), "
37
+ "respond with ONLY a JSON object in this format:",
38
+ '{"type": "final_answer", "content": "<your answer>"}',
39
+ "",
40
+ "IMPORTANT: Your entire response must be a single JSON object. "
41
+ "Do not include any other text, markdown, or explanation outside the JSON.",
42
+ ]
43
+ return "\n".join(lines)
44
+
45
+
46
+ def parse_simulated_response(text: str, tools: ToolRegistry) -> dict[str, Any]:
47
+ """Parse the model's response into a tool call or final answer dict.
48
+
49
+ Returns one of:
50
+ - ``{"type": "tool_call", "name": str, "arguments": dict}``
51
+ - ``{"type": "final_answer", "content": str}``
52
+ """
53
+ cleaned = clean_json_text(text).strip()
54
+
55
+ # Try JSON parse
56
+ try:
57
+ obj = json.loads(cleaned)
58
+ except (json.JSONDecodeError, ValueError):
59
+ # Non-JSON text → treat as final answer
60
+ logger.debug("Response is not valid JSON, treating as final answer")
61
+ return {"type": "final_answer", "content": text.strip()}
62
+
63
+ if not isinstance(obj, dict):
64
+ return {"type": "final_answer", "content": text.strip()}
65
+
66
+ # Explicit type discriminator
67
+ resp_type = obj.get("type")
68
+
69
+ if resp_type == "tool_call":
70
+ return {
71
+ "type": "tool_call",
72
+ "name": obj.get("name", ""),
73
+ "arguments": obj.get("arguments", {}),
74
+ }
75
+
76
+ if resp_type == "final_answer":
77
+ return {
78
+ "type": "final_answer",
79
+ "content": obj.get("content", ""),
80
+ }
81
+
82
+ # Infer type from keys when "type" is missing
83
+ if "name" in obj and "arguments" in obj:
84
+ logger.debug("Inferred tool_call from keys (no 'type' field)")
85
+ return {
86
+ "type": "tool_call",
87
+ "name": obj["name"],
88
+ "arguments": obj.get("arguments", {}),
89
+ }
90
+
91
+ if "content" in obj:
92
+ logger.debug("Inferred final_answer from keys (no 'type' field)")
93
+ return {
94
+ "type": "final_answer",
95
+ "content": obj["content"],
96
+ }
97
+
98
+ # Unrecognised JSON structure → final answer with the raw text
99
+ return {"type": "final_answer", "content": text.strip()}
100
+
101
+
102
+ def format_tool_result(tool_name: str, result: Any) -> str:
103
+ """Format a tool execution result as a user message for the next round."""
104
+ if isinstance(result, str):
105
+ result_str = result
106
+ else:
107
+ try:
108
+ result_str = json.dumps(result)
109
+ except (TypeError, ValueError):
110
+ result_str = str(result)
111
+
112
+ return (
113
+ f"Tool '{tool_name}' returned:\n{result_str}\n\n"
114
+ "Continue using the JSON format. Either call another tool or provide your final answer."
115
+ )
@@ -109,6 +109,24 @@ class ToolDefinition:
109
109
  "input_schema": self.parameters,
110
110
  }
111
111
 
112
+ def to_prompt_format(self) -> str:
113
+ """Plain-text description suitable for prompt-based tool calling."""
114
+ lines = [f"Tool: {self.name}", f" Description: {self.description}", " Parameters:"]
115
+ props = self.parameters.get("properties", {})
116
+ required = set(self.parameters.get("required", []))
117
+ if not props:
118
+ lines.append(" (none)")
119
+ else:
120
+ for pname, pschema in props.items():
121
+ ptype = pschema.get("type", "string")
122
+ req_label = "required" if pname in required else "optional"
123
+ desc = pschema.get("description", "")
124
+ line = f" - {pname} ({ptype}, {req_label})"
125
+ if desc:
126
+ line += f": {desc}"
127
+ lines.append(line)
128
+ return "\n".join(lines)
129
+
112
130
 
113
131
  def tool_from_function(
114
132
  fn: Callable[..., Any], *, name: str | None = None, description: str | None = None
@@ -244,6 +262,10 @@ class ToolRegistry:
244
262
  def to_anthropic_format(self) -> list[dict[str, Any]]:
245
263
  return [td.to_anthropic_format() for td in self._tools.values()]
246
264
 
265
+ def to_prompt_format(self) -> str:
266
+ """Join all tool descriptions into a single plain-text block."""
267
+ return "\n\n".join(td.to_prompt_format() for td in self._tools.values())
268
+
247
269
  # ------------------------------------------------------------------
248
270
  # Execution
249
271
  # ------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prompture
3
- Version: 0.0.47.dev1
3
+ Version: 0.0.47.dev2
4
4
  Summary: Ask LLMs to return structured JSON and run cross-model tests. API-first.
5
5
  Author-email: Juan Denis <juan@vene.co>
6
6
  License-Expression: MIT
@@ -83,7 +83,7 @@ print(person.name) # Maria
83
83
  - **Stepwise extraction** — Per-field prompts with smart type coercion (shorthand numbers, multilingual booleans, dates)
84
84
  - **Field registry** — 50+ predefined extraction fields with template variables and Pydantic integration
85
85
  - **Conversations** — Stateful multi-turn sessions with sync and async support
86
- - **Tool use** — Function calling and streaming across supported providers
86
+ - **Tool use** — Function calling and streaming across supported providers, with automatic prompt-based simulation for models without native tool support
87
87
  - **Caching** — Built-in response cache with memory, SQLite, and Redis backends
88
88
  - **Plugin system** — Register custom drivers via entry points
89
89
  - **Usage tracking** — Token counts and cost calculation on every call
@@ -296,6 +296,39 @@ response = conv.send("What is the capital of France?")
296
296
  follow_up = conv.send("What about Germany?") # retains context
297
297
  ```
298
298
 
299
+ ### Tool Use
300
+
301
+ Register Python functions as tools the LLM can call during a conversation:
302
+
303
+ ```python
304
+ from prompture import Conversation, ToolRegistry
305
+
306
+ registry = ToolRegistry()
307
+
308
+ @registry.tool
309
+ def get_weather(city: str, units: str = "celsius") -> str:
310
+ """Get the current weather for a city."""
311
+ return f"Weather in {city}: 22 {units}"
312
+
313
+ conv = Conversation("openai/gpt-4", tools=registry)
314
+ result = conv.ask("What's the weather in London?")
315
+ ```
316
+
317
+ For models without native function calling (Ollama, LM Studio, etc.), Prompture automatically simulates tool use by describing tools in the prompt and parsing structured JSON responses:
318
+
319
+ ```python
320
+ # Auto-detect: uses native tool calling if available, simulation otherwise
321
+ conv = Conversation("ollama/llama3.1:8b", tools=registry, simulated_tools="auto")
322
+
323
+ # Force simulation even on capable models
324
+ conv = Conversation("openai/gpt-4", tools=registry, simulated_tools=True)
325
+
326
+ # Disable tool use entirely
327
+ conv = Conversation("openai/gpt-4", tools=registry, simulated_tools=False)
328
+ ```
329
+
330
+ The simulation loop describes tools in the system prompt, asks the model to respond with JSON (`tool_call` or `final_answer`), executes tools, and feeds results back — all transparent to the caller.
331
+
299
332
  ### Model Discovery
300
333
 
301
334
  Auto-detect available models from configured providers:
@@ -81,6 +81,7 @@ prompture/serialization.py
81
81
  prompture/server.py
82
82
  prompture/session.py
83
83
  prompture/settings.py
84
+ prompture/simulated_tools.py
84
85
  prompture/tools.py
85
86
  prompture/tools_schema.py
86
87
  prompture/validator.py
File without changes
File without changes