weft-rag 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. weft_agent/__init__.py +69 -0
  2. weft_agent/command.py +147 -0
  3. weft_agent/command_tools.py +164 -0
  4. weft_agent/loop.py +214 -0
  5. weft_agent/payload.py +110 -0
  6. weft_agent/prompts.py +55 -0
  7. weft_agent/py.typed +0 -0
  8. weft_agent/render.py +53 -0
  9. weft_agent/tools.py +120 -0
  10. weft_blob/__init__.py +106 -0
  11. weft_blob/contract.py +76 -0
  12. weft_blob/filesystem_store.py +241 -0
  13. weft_blob/keys.py +81 -0
  14. weft_blob/payload.py +32 -0
  15. weft_blob/py.typed +0 -0
  16. weft_chunk/__init__.py +61 -0
  17. weft_chunk/carry.py +35 -0
  18. weft_chunk/contract.py +81 -0
  19. weft_chunk/fixed_size.py +161 -0
  20. weft_chunk/payload.py +34 -0
  21. weft_chunk/property.py +27 -0
  22. weft_chunk/py.typed +0 -0
  23. weft_chunk/table_rows.py +134 -0
  24. weft_clean/__init__.py +75 -0
  25. weft_clean/artifact_remover.py +136 -0
  26. weft_clean/contract.py +78 -0
  27. weft_clean/dictionary_spacing.py +145 -0
  28. weft_clean/hyphenation.py +98 -0
  29. weft_clean/language.py +36 -0
  30. weft_clean/property.py +77 -0
  31. weft_clean/py.typed +0 -0
  32. weft_clean/table_linearizer.py +116 -0
  33. weft_clean/unicode_normalizer.py +90 -0
  34. weft_clean/whitespace.py +94 -0
  35. weft_cli/__init__.py +5 -0
  36. weft_cli/answer_envelope.py +112 -0
  37. weft_cli/argparse_gen.py +150 -0
  38. weft_cli/ask.py +238 -0
  39. weft_cli/cli.py +848 -0
  40. weft_cli/command_table.py +130 -0
  41. weft_cli/commands.py +1383 -0
  42. weft_cli/compile.py +424 -0
  43. weft_cli/config_commands.py +175 -0
  44. weft_cli/config_surface.py +431 -0
  45. weft_cli/confirm.py +214 -0
  46. weft_cli/contract_reference.py +623 -0
  47. weft_cli/deletion.py +148 -0
  48. weft_cli/error_envelope.py +117 -0
  49. weft_cli/eval_commands.py +1132 -0
  50. weft_cli/eval_scoring.py +357 -0
  51. weft_cli/exit_codes.py +175 -0
  52. weft_cli/fanout.py +151 -0
  53. weft_cli/ingest.py +1087 -0
  54. weft_cli/installed_versions.py +47 -0
  55. weft_cli/llm_roles.py +228 -0
  56. weft_cli/output.py +33 -0
  57. weft_cli/pack_attribution.py +299 -0
  58. weft_cli/participation.py +175 -0
  59. weft_cli/permission_policy.py +144 -0
  60. weft_cli/pipeline_catalogue.py +324 -0
  61. weft_cli/pipeline_commands.py +439 -0
  62. weft_cli/pipeline_diff.py +149 -0
  63. weft_cli/plugins_report.py +362 -0
  64. weft_cli/preview.py +223 -0
  65. weft_cli/py.typed +0 -0
  66. weft_cli/reconcile.py +215 -0
  67. weft_cli/reconcile_policy.py +114 -0
  68. weft_cli/registry_bootstrap.py +616 -0
  69. weft_cli/render.py +1128 -0
  70. weft_cli/repl.py +425 -0
  71. weft_cli/route_ask.py +669 -0
  72. weft_cli/run_services.py +994 -0
  73. weft_cli/service_roles.py +98 -0
  74. weft_cli/services.py +276 -0
  75. weft_cli/session.py +145 -0
  76. weft_cli/sinks.py +300 -0
  77. weft_cli/skew.py +154 -0
  78. weft_cli/tracing_status.py +48 -0
  79. weft_command/__init__.py +47 -0
  80. weft_command/catalogue.py +97 -0
  81. weft_command/contract.py +221 -0
  82. weft_command/invocation.py +85 -0
  83. weft_command/permission.py +44 -0
  84. weft_command/py.typed +0 -0
  85. weft_command/render.py +67 -0
  86. weft_docling/__init__.py +107 -0
  87. weft_docling/pdf_layout_model.py +230 -0
  88. weft_docling/pipelines/index-pdf-learned.yaml +48 -0
  89. weft_docling/py.typed +0 -0
  90. weft_docling/weights.py +66 -0
  91. weft_embed/__init__.py +52 -0
  92. weft_embed/contract.py +74 -0
  93. weft_embed/hash_embedder.py +100 -0
  94. weft_embed/py.typed +0 -0
  95. weft_enhance/__init__.py +47 -0
  96. weft_enhance/contract.py +86 -0
  97. weft_enhance/keybert_stand_in.py +144 -0
  98. weft_enhance/keywords.py +19 -0
  99. weft_enhance/py.typed +0 -0
  100. weft_eval/__init__.py +316 -0
  101. weft_eval/aggregate.py +306 -0
  102. weft_eval/at_threshold.py +79 -0
  103. weft_eval/contract.py +263 -0
  104. weft_eval/embedding_metrics.py +124 -0
  105. weft_eval/embedding_support.py +55 -0
  106. weft_eval/falsify.py +288 -0
  107. weft_eval/harness.py +180 -0
  108. weft_eval/ir_metrics.py +163 -0
  109. weft_eval/judges.py +432 -0
  110. weft_eval/lexical.py +256 -0
  111. weft_eval/offline.py +155 -0
  112. weft_eval/pricing.py +150 -0
  113. weft_eval/prompts.py +353 -0
  114. weft_eval/py.typed +0 -0
  115. weft_eval/qa_metrics.py +130 -0
  116. weft_eval/run_record.py +249 -0
  117. weft_extract/__init__.py +101 -0
  118. weft_extract/accept.py +70 -0
  119. weft_extract/contract.py +163 -0
  120. weft_extract/payload.py +210 -0
  121. weft_extract/py.typed +0 -0
  122. weft_extract/render.py +162 -0
  123. weft_extract/table_text.py +98 -0
  124. weft_extract/text.py +118 -0
  125. weft_generate/__init__.py +115 -0
  126. weft_generate/cited_answer.py +274 -0
  127. weft_generate/contract.py +58 -0
  128. weft_generate/contradiction.py +294 -0
  129. weft_generate/page.py +61 -0
  130. weft_generate/payload.py +148 -0
  131. weft_generate/prompts.py +279 -0
  132. weft_generate/py.typed +0 -0
  133. weft_generate/refine.py +399 -0
  134. weft_generate/representation.py +72 -0
  135. weft_index/__init__.py +102 -0
  136. weft_index/adrap.py +638 -0
  137. weft_index/contract.py +141 -0
  138. weft_index/hypothetical_questions.py +163 -0
  139. weft_index/payload.py +170 -0
  140. weft_index/prompts.py +157 -0
  141. weft_index/py.typed +0 -0
  142. weft_index/raptor.py +1122 -0
  143. weft_kg/__init__.py +255 -0
  144. weft_kg/adjudication.py +123 -0
  145. weft_kg/atomicity.py +121 -0
  146. weft_kg/bridges.py +267 -0
  147. weft_kg/commands.py +474 -0
  148. weft_kg/contract.py +168 -0
  149. weft_kg/cooccurrence.py +83 -0
  150. weft_kg/extraction.py +371 -0
  151. weft_kg/names.py +117 -0
  152. weft_kg/payload.py +229 -0
  153. weft_kg/pipelines/graph-2hop-then-generate.yaml +26 -0
  154. weft_kg/pipelines/graph-and-vector-rrf.yaml +38 -0
  155. weft_kg/pipelines/graph-then-generate.yaml +28 -0
  156. weft_kg/pipelines/graph-then-rerank.yaml +25 -0
  157. weft_kg/pipelines/index-with-cooccurrence.yaml +15 -0
  158. weft_kg/pipelines/index-with-facts-openai.yaml +47 -0
  159. weft_kg/pipelines/index-with-facts.yaml +19 -0
  160. weft_kg/pipelines/index-with-graph.yaml +20 -0
  161. weft_kg/prompts.py +355 -0
  162. weft_kg/py.typed +0 -0
  163. weft_kg/resolution.py +360 -0
  164. weft_kg/retrieval.py +176 -0
  165. weft_kg/schema.py +315 -0
  166. weft_kg/store.py +1742 -0
  167. weft_kg/traversal.py +221 -0
  168. weft_llm/__init__.py +91 -0
  169. weft_llm/client.py +318 -0
  170. weft_llm/contract.py +158 -0
  171. weft_llm/errors.py +229 -0
  172. weft_llm/loop_guard.py +238 -0
  173. weft_llm/models.py +156 -0
  174. weft_llm/payload.py +169 -0
  175. weft_llm/py.typed +0 -0
  176. weft_llm/retry.py +179 -0
  177. weft_llm/roles.py +83 -0
  178. weft_llm/scripted.py +99 -0
  179. weft_openai/__init__.py +144 -0
  180. weft_openai/embedder.py +362 -0
  181. weft_openai/llm.py +434 -0
  182. weft_openai/py.typed +0 -0
  183. weft_openai/settings.py +54 -0
  184. weft_openai/vision.py +235 -0
  185. weft_otel/__init__.py +107 -0
  186. weft_otel/provider.py +100 -0
  187. weft_otel/py.typed +0 -0
  188. weft_otel/settings.py +88 -0
  189. weft_pdf/__init__.py +79 -0
  190. weft_pdf/document.py +567 -0
  191. weft_pdf/pdf_layout.py +384 -0
  192. weft_pdf/pdf_text.py +314 -0
  193. weft_pdf/py.typed +0 -0
  194. weft_prompts/__init__.py +61 -0
  195. weft_prompts/cascade.py +231 -0
  196. weft_prompts/contract.py +80 -0
  197. weft_prompts/errors.py +58 -0
  198. weft_prompts/py.typed +0 -0
  199. weft_prompts/registry.py +73 -0
  200. weft_prompts/rescue.py +65 -0
  201. weft_prompts/template.py +84 -0
  202. weft_prompts/typed_prompt.py +121 -0
  203. weft_qdrant/__init__.py +70 -0
  204. weft_qdrant/py.typed +0 -0
  205. weft_qdrant/settings.py +54 -0
  206. weft_qdrant/store.py +688 -0
  207. weft_rag-2.4.0.dist-info/METADATA +108 -0
  208. weft_rag-2.4.0.dist-info/RECORD +282 -0
  209. weft_rag-2.4.0.dist-info/WHEEL +4 -0
  210. weft_rag-2.4.0.dist-info/entry_points.txt +25 -0
  211. weft_rag-2.4.0.dist-info/licenses/LICENSE +21 -0
  212. weft_rag-2.4.0.dist-info/licenses/NOTICE +77 -0
  213. weft_retrieve/__init__.py +666 -0
  214. weft_retrieve/boolean.py +461 -0
  215. weft_retrieve/collapse.py +283 -0
  216. weft_retrieve/contract.py +302 -0
  217. weft_retrieve/corrective.py +263 -0
  218. weft_retrieve/engine.py +223 -0
  219. weft_retrieve/fusion.py +616 -0
  220. weft_retrieve/graded.py +228 -0
  221. weft_retrieve/hybrid.py +221 -0
  222. weft_retrieve/iterative.py +422 -0
  223. weft_retrieve/multi_arm.py +145 -0
  224. weft_retrieve/multi_retriever.py +164 -0
  225. weft_retrieve/no_retrieval.py +62 -0
  226. weft_retrieve/payload.py +441 -0
  227. weft_retrieve/pipelines/boolean-then-retrieve.yaml +29 -0
  228. weft_retrieve/pipelines/broad-and-refined-rrf.yaml +66 -0
  229. weft_retrieve/pipelines/contradiction-aware.yaml +24 -0
  230. weft_retrieve/pipelines/corrective-retrieve.yaml +56 -0
  231. weft_retrieve/pipelines/draft-then-refine.yaml +31 -0
  232. weft_retrieve/pipelines/grade-then-generate.yaml +24 -0
  233. weft_retrieve/pipelines/hybrid-then-generate.yaml +49 -0
  234. weft_retrieve/pipelines/hyde-then-retrieve.yaml +27 -0
  235. weft_retrieve/pipelines/index-messy-text.yaml +36 -0
  236. weft_retrieve/pipelines/index-openai.yaml +32 -0
  237. weft_retrieve/pipelines/index-pdf-described.yaml +21 -0
  238. weft_retrieve/pipelines/index-pdf-rows.yaml +22 -0
  239. weft_retrieve/pipelines/index-pdf-text.yaml +20 -0
  240. weft_retrieve/pipelines/index-pdf-undescribed.yaml +12 -0
  241. weft_retrieve/pipelines/index-pdf.yaml +27 -0
  242. weft_retrieve/pipelines/index-polish.yaml +21 -0
  243. weft_retrieve/pipelines/index-qdrant.yaml +21 -0
  244. weft_retrieve/pipelines/index-text.yaml +64 -0
  245. weft_retrieve/pipelines/index-with-adrap.yaml +42 -0
  246. weft_retrieve/pipelines/index-with-deep-raptor.yaml +56 -0
  247. weft_retrieve/pipelines/index-with-keywords.yaml +15 -0
  248. weft_retrieve/pipelines/index-with-questions.yaml +23 -0
  249. weft_retrieve/pipelines/index-with-raptor.yaml +95 -0
  250. weft_retrieve/pipelines/iterative-retrieve.yaml +32 -0
  251. weft_retrieve/pipelines/multi-query-then-retrieve.yaml +27 -0
  252. weft_retrieve/pipelines/no-retrieval.yaml +9 -0
  253. weft_retrieve/pipelines/preview-markdown.yaml +18 -0
  254. weft_retrieve/pipelines/preview-plain.yaml +25 -0
  255. weft_retrieve/pipelines/raptor-and-leaves-rrf.yaml +78 -0
  256. weft_retrieve/pipelines/rerank-then-generate.yaml +24 -0
  257. weft_retrieve/pipelines/retrieve-then-generate.yaml +9 -0
  258. weft_retrieve/pipelines/rewrite-then-retrieve.yaml +25 -0
  259. weft_retrieve/pipelines/route-by-score.yaml +24 -0
  260. weft_retrieve/pipelines/route-fixed.yaml +23 -0
  261. weft_retrieve/pipelines/route.yaml +4 -0
  262. weft_retrieve/pipelines/step-back-then-retrieve.yaml +22 -0
  263. weft_retrieve/pipelines/summarise-then-generate.yaml +45 -0
  264. weft_retrieve/postqfrap.py +372 -0
  265. weft_retrieve/prompts.py +1090 -0
  266. weft_retrieve/py.typed +0 -0
  267. weft_retrieve/repack.py +171 -0
  268. weft_retrieve/rerank.py +216 -0
  269. weft_retrieve/routing.py +682 -0
  270. weft_retrieve/sufficiency.py +262 -0
  271. weft_retrieve/transforms.py +742 -0
  272. weft_retrieve/vector_top_k.py +280 -0
  273. weft_store/__init__.py +152 -0
  274. weft_store/contract.py +964 -0
  275. weft_store/fields.py +249 -0
  276. weft_store/pgvector_store.py +1105 -0
  277. weft_store/py.typed +0 -0
  278. weft_store/rehydrate.py +178 -0
  279. weft_vision/__init__.py +66 -0
  280. weft_vision/contract.py +69 -0
  281. weft_vision/describe_figure.py +203 -0
  282. weft_vision/py.typed +0 -0
weft_agent/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """First-party agentic front end — task **7.1**, `docs/05-grilling-sessions.md` G8.
2
+
3
+ **A pack, not an agentic REPL.** `docs/03`'s governing rule keeps logic out of the driving
4
+ adapter; G8 settled that the agent lands after release, built against `weft-kernel` and
5
+ `weft-rag`'s *published, versioned* API rather than a moving one — so it registers through the
6
+ same `weft.packs` entry point a third party would use, and nothing here reaches into
7
+ `weft_cli`. `tests/architecture/test_ff21_agent_is_an_ordinary_pack.py`'s own module docstring
8
+ states the three properties that test falsifies; this pack's whole job at 7.1 is to be a
9
+ distribution that satisfies all three and nothing more.
10
+
11
+ **Registers `next-action` against `weft_prompts.contract.Prompt`, and, since task 7.4, `agent`
12
+ against `weft_command.contract.Command` — both contracts published by `weft-rag`, never by this
13
+ pack**, the structural half of `01` -> Phase 7's own claim, "built against nothing but the
14
+ released API". `weft_agent.command.AgentCommand` is the loop (task 7.2) and the tool catalogue
15
+ (task 7.3) reached through the one surface a stranger's own command would use; see that
16
+ module's own docstring for the ambient-service seam it needed `weft_cli.cli.run_command` to
17
+ open. `AgentCommandResult`'s own renderer is registered the identical way any pack's is
18
+ (`weft_kernel.discovery.PackRegistrar.add_renderer`, task 6.20's own seam).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from functools import partial
24
+
25
+ from pydantic import BaseModel, ConfigDict
26
+
27
+ from weft_agent.command import AgentCommand, AgentCommandResult
28
+ from weft_agent.prompts import NEXT_ACTION_NAME, NextActionPrompt
29
+ from weft_agent.render import render_agent
30
+ from weft_command.contract import Command
31
+ from weft_kernel.discovery import PackRegistrar
32
+ from weft_prompts.contract import Prompt
33
+
34
+
35
+ class Settings(BaseModel):
36
+ """`weft-agent`'s pack settings — task 7.2's own: the loop's step budget.
37
+
38
+ `max_steps` is an operator's decision, not a number baked into `weft_agent.loop`, per that
39
+ module's own docstring. Defaulted to 10: enough for a handful of tool calls plus a final
40
+ answer on the kind of multi-step retrieval question this pack exists for, without letting a
41
+ model stuck in a loop run away unbounded.
42
+ """
43
+
44
+ model_config = ConfigDict(frozen=True, extra="forbid")
45
+
46
+ max_steps: int = 10
47
+
48
+
49
+ def register(registrar: PackRegistrar, settings: Settings) -> None:
50
+ """Register `NextActionPrompt` as `"next-action"` for `Prompt`, `AgentCommand` as `"agent"`
51
+ for `Command`, and that command's own renderer.
52
+
53
+ `settings` is accepted, per the fixed two-parameter shape every pack declares, and unused —
54
+ `AgentCommand.run` reads `Settings` itself, for the reason its own module docstring gives
55
+ (a module-scope import here would be circular).
56
+ """
57
+ registrar.add(Prompt, NEXT_ACTION_NAME, NextActionPrompt)
58
+ registrar.add(Command, "agent", partial(AgentCommand, settings))
59
+ registrar.add_renderer(AgentCommandResult, render_agent)
60
+
61
+
62
+ __all__ = [
63
+ "NEXT_ACTION_NAME",
64
+ "AgentCommand",
65
+ "AgentCommandResult",
66
+ "NextActionPrompt",
67
+ "Settings",
68
+ "register",
69
+ ]
weft_agent/command.py ADDED
@@ -0,0 +1,147 @@
1
+ """`weft agent` — the command that drives `weft_agent.loop.run_agent` through the published
2
+ `weft_command.contract.Command` surface. Ledger task **7.4**.
3
+
4
+ **This is the seam Phase 8's own close review predicted, one phase early.** `docs/lessons.md`
5
+ `L5.15`'s shape — a *consuming* side of `ServiceRegistry` anyone can reach (`ctx.require`) and a
6
+ *producing* side reachable only by editing `weft-cli` — is what made this command impossible to
7
+ write honestly until `weft_cli.cli.run_command` registered the run's `LLM` (and `Prompts`,
8
+ `TokenSink`) by their *published contract types*, not only under its own private `Dependencies`.
9
+ See that module's own docstring for the repair; nothing here depends on `weft_cli` at all, and
10
+ `weft_agent` must not — that pack boundary is task 7.1's own claim and this task does not reopen
11
+ it.
12
+
13
+ **`registry = ctx.require(Registry)`, not a name this command resolves itself.** The agent's own
14
+ tool catalogue is built from whatever `Registry` the run assembled (`weft_agent.command_tools.
15
+ command_tools`), which is `ctx.require`'s exact job: a service every stage may need regardless of
16
+ what pipeline runs, resolved by type with no name to disambiguate — there is exactly one registry
17
+ per run.
18
+
19
+ **The command declares `write`, not more.** `03` → *Permissions*: a command declares the highest
20
+ class it may reach, and the agent may reach `write` commands (`weft_agent.tools.
21
+ REACHABLE_CLASSES`) but never `overwrite`/`destroy` — G12's ceiling, held by that catalogue and by
22
+ `weft_agent.command_tools.RefusingConsent`, not by this declaration. Declaring `destroy` here
23
+ would ask a human to confirm something the agent cannot do anyway.
24
+
25
+ **`max_steps` comes from `weft_agent.Settings`, this pack's own configured budget** — an
26
+ operator's decision (that model's own docstring), read here rather than hard-coded a second time.
27
+ Imported inside `run`, not at module scope: `weft_agent/__init__.py` imports `AgentCommand` from
28
+ this module to register it, so a module-scope `from weft_agent import Settings` here would be a
29
+ circular import at the moment `weft_agent` itself is still being defined.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import TYPE_CHECKING
35
+
36
+ if TYPE_CHECKING:
37
+ from weft_agent import Settings
38
+
39
+ from collections.abc import Mapping
40
+ from typing import ClassVar, cast
41
+
42
+ from pydantic import BaseModel, ConfigDict
43
+
44
+ from weft_agent.command_tools import command_tools
45
+ from weft_agent.loop import AgentTool, StopReason, run_agent
46
+ from weft_agent.payload import AgentTranscript
47
+ from weft_command.contract import CommandResult
48
+ from weft_command.permission import PermissionClass
49
+ from weft_kernel.context import Context
50
+ from weft_kernel.payload import Outcome, Produced
51
+ from weft_kernel.registry import Registry
52
+ from weft_llm.contract import LLM
53
+
54
+ _AGENT_HELP = (
55
+ "drive a goal to an answer, calling the run's published commands as tools "
56
+ "(docs/01-high-level-plan.md -> Phase 7)"
57
+ )
58
+
59
+
60
+ class AgentArgs(BaseModel):
61
+ """What `weft agent` needs from a caller: the goal to work toward, and nothing else."""
62
+
63
+ model_config = ConfigDict(frozen=True, extra="forbid")
64
+
65
+ goal: str
66
+
67
+
68
+ class AgentCommandResult(CommandResult):
69
+ """What one agent run produced — the full transcript, the final answer if there is one,
70
+ and why the loop stopped. The same three facts `weft_agent.loop.AgentOutcome` carries,
71
+ restated as a `CommandResult` so `weft_cli`/a stranger's renderer can format them without
72
+ depending on `weft_agent.loop` itself.
73
+ """
74
+
75
+ transcript: AgentTranscript
76
+ final_answer: str | None
77
+ stopped_because: StopReason
78
+
79
+
80
+ class AgentCommand:
81
+ """`weft agent` — one ReAct run toward a goal, its tools drawn from the run's own
82
+ published command surface. See this module's own docstring for the seam it needed opened.
83
+ """
84
+
85
+ args_model: ClassVar[type[BaseModel]] = AgentArgs
86
+ result_model: ClassVar[type[CommandResult]] = AgentCommandResult
87
+ permission_class: ClassVar[PermissionClass] = PermissionClass.WRITE
88
+ help: ClassVar[str] = _AGENT_HELP
89
+
90
+ def __init__(self, settings: Settings | None = None, config: object = None) -> None:
91
+ """`settings` bound at construction, `config` accepted and unused.
92
+
93
+ **Bound rather than constructed in `run`**, which is what `weft_qdrant` and `weft_openai`
94
+ already do through `functools.partial`. The first version built a fresh `Settings()` per
95
+ run, so `[packs.agent] max_steps = 3` validated, was accepted and was silently discarded —
96
+ a knob that reads as configurable and is not. `config` is the per-plugin argument every
97
+ factory receives and an agent has nothing per-stage to configure.
98
+ """
99
+ del config
100
+ # Imported here, not at module scope: `weft_agent/__init__.py` imports this module in
101
+ # order to register the command, so a module-scope import would be a cycle — the same
102
+ # reason the docstring above already gives for `Settings` not appearing in the imports.
103
+ from weft_agent import Settings # noqa: PLC0415
104
+
105
+ self._settings = settings if settings is not None else Settings()
106
+
107
+ @property
108
+ def max_steps(self) -> int:
109
+ """The configured step budget this command will run under."""
110
+ return self._settings.max_steps
111
+
112
+ async def run(self, args: BaseModel, ctx: Context) -> Outcome[CommandResult]:
113
+ # Local import: `weft_agent/__init__.py` imports `AgentCommand` from this module to
114
+ # register it, so a module-scope `from weft_agent import Settings` here would be a
115
+ # circular import at the moment `weft_agent` itself is still being defined — see this
116
+ # module's own docstring.
117
+
118
+ agent_args = cast(AgentArgs, args)
119
+ registry = ctx.require(Registry)
120
+ llm = ctx.require(LLM)
121
+ tools = command_tools(registry)
122
+
123
+ outcome = await run_agent(
124
+ goal=agent_args.goal,
125
+ # `CommandTool` satisfies `AgentTool` structurally at runtime (it carries
126
+ # `description` and an async `call`) but not statically: `AgentTool.description`
127
+ # is declared `ClassVar[str]`, while `CommandTool` sets it as an instance
128
+ # attribute in `__init__`. `weft_agent.loop`'s own `NextActionPrompt`/`Prompt`
129
+ # pairing is the identical, already-established shape for "a static check cannot
130
+ # see a structural conformance that holds at runtime" — this is that same cast,
131
+ # not a new pattern.
132
+ tools=cast("Mapping[str, AgentTool]", tools),
133
+ llm=llm,
134
+ ctx=ctx,
135
+ max_steps=self.max_steps,
136
+ )
137
+
138
+ return Produced(
139
+ value=AgentCommandResult(
140
+ transcript=outcome.transcript,
141
+ final_answer=outcome.final_answer,
142
+ stopped_because=outcome.stopped_because,
143
+ )
144
+ )
145
+
146
+
147
+ __all__ = ["AgentArgs", "AgentCommand", "AgentCommandResult"]
@@ -0,0 +1,164 @@
1
+ """The agent reaches Weft only through the published command surface — ledger task **7.3**.
2
+
3
+ `docs/03-cli.md` → *Two modes, one implementation*, and `docs/05-grilling-sessions.md` → G12.
4
+ See `tests/unit/weft_agent/test_command_tools.py`'s own module docstring for the three properties
5
+ this module makes true; restated here only as pointers to where each lives:
6
+
7
+ *(a)* `CommandTool.call` reaches a `Command` only through `weft_command.invocation.invoke` — never
8
+ `instance.run(...)` directly — so the permission gate task 7.0 moved onto that seam applies to the
9
+ agent exactly as it applies to `weft_cli`. `tests/unit/weft_agent/test_command_tools.py::
10
+ test_the_tool_runs_a_command_only_through_the_seam` asserts this over the module's own source.
11
+
12
+ *(b)* The observation returned to the model is built from the typed `Outcome[CommandResult]`
13
+ `invoke` returns, never from `weft_command.render.Rendered` — this module imports nothing from
14
+ `weft_cli` at all, per `weft_agent`'s own pack-boundary rule (task 7.1).
15
+
16
+ *(c)* `RefusingConsent` is this pack's answer to `weft_command.invocation.Consent` — G12's
17
+ settled ceiling ("nothing but a TTY counts as consent") expressed as code: every
18
+ `overwrite`/`destroy`-class command is refused, and the refusal is returned as an observation so
19
+ the model can read it and choose otherwise, never raised out of the loop.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from collections.abc import Mapping
26
+ from typing import Final, cast
27
+
28
+ from pydantic import BaseModel
29
+
30
+ from weft_agent.tools import tool_catalogue
31
+ from weft_command.catalogue import help_of
32
+ from weft_command.contract import Command, CommandResult
33
+ from weft_command.invocation import invoke
34
+ from weft_command.permission import PermissionClass
35
+ from weft_kernel.context import Context
36
+ from weft_kernel.errors import WeftError
37
+ from weft_kernel.payload import NothingToProduce, Outcome, Produced
38
+ from weft_kernel.registry import Registry, unwrap_factory
39
+
40
+ #: G12's ceiling, read the other way round from `weft_agent.tools.REACHABLE_CLASSES`: these are
41
+ #: the two classes nothing but a TTY may answer for, enumerated explicitly (rather than as
42
+ #: "everything but read/write/network") so a sixth `PermissionClass` member is permitted by
43
+ #: default and a reviewer has to notice it was never added here — the population this module
44
+ #: actually decides over, per `docs/lessons.md` `L6.4`.
45
+ _REFUSED_CLASSES: Final[frozenset[PermissionClass]] = frozenset(
46
+ {PermissionClass.OVERWRITE, PermissionClass.DESTROY}
47
+ )
48
+
49
+
50
+ class ConsentRefusedError(WeftError):
51
+ """A `PermissionClass.OVERWRITE`/`DESTROY` command, refused because nothing here is a TTY.
52
+
53
+ Raised by `RefusingConsent.decide` and caught by `CommandTool.call`, never allowed to escape
54
+ the loop — see this module's own docstring, property *(c)*.
55
+ """
56
+
57
+
58
+ class RefusingConsent:
59
+ """`weft_command.invocation.Consent`, answered the way G12 settled it for an agent.
60
+
61
+ `permission_class` is read off `instance` with `getattr`, defensively — the identical
62
+ discipline `weft_cli.confirm.gate` uses for the same attribute, and for the same reason:
63
+ `permission_class` is a `Command.required_declarations` name, never a required `isinstance`
64
+ member, so nothing guarantees a stranger's `Command` carries it beyond registration having
65
+ already refused it if it did not.
66
+ """
67
+
68
+ async def decide(self, *, command_name: str, instance: object, args: BaseModel) -> None:
69
+ del args
70
+ permission_class = getattr(instance, "permission_class", None)
71
+ if permission_class in _REFUSED_CLASSES:
72
+ refused = cast(PermissionClass, permission_class)
73
+ raise ConsentRefusedError(
74
+ f"'{command_name}' is a {refused.value}-class command. Consent cannot be given "
75
+ "without a TTY, and nothing in this run is one, so it is refused."
76
+ )
77
+
78
+
79
+ def _observation_of(outcome: Outcome[CommandResult]) -> str:
80
+ """The typed `Outcome` a `Command` produced, turned into the string an `AgentTool` returns.
81
+
82
+ Never built from `Rendered` — see this module's own docstring, property *(b)*. A `Produced`
83
+ serialises its own value's fields; `NothingToProduce`/`Failed` carry only a `reason`, which is
84
+ exactly what an agent needs to decide what to try next.
85
+ """
86
+ if isinstance(outcome, Produced):
87
+ return json.dumps(outcome.value.model_dump(mode="json"))
88
+ if isinstance(outcome, NothingToProduce):
89
+ return f"nothing to produce: {outcome.reason}"
90
+ # `Failed` by elimination, and **the type checker is what says so** — `pyright` narrows
91
+ # `Outcome` to it here and rejects a further `isinstance` as unnecessary. The first draft of
92
+ # this function asserted the same fact instead; an `assert` is stripped under `-O` and would
93
+ # have left the loop a bare `None` where it expects a string, while proving nothing a build
94
+ # was not already proving. If `weft-kernel` ever grows a fourth `Outcome` member, this line
95
+ # fails the gate rather than silently mis-reading one.
96
+ return f"command failed: {outcome.reason}"
97
+
98
+
99
+ class CommandTool:
100
+ """One registered `Command`, offered to the agent's loop as an `AgentTool`.
101
+
102
+ Constructed directly against a `Registry` and a registered name, deliberately independent of
103
+ `weft_agent.tools.tool_catalogue`'s own filtering — `command_tools` below is what wires the
104
+ two together for the ordinary case, but a `CommandTool` built for a `destroy`-class command
105
+ the catalogue would never offer must still refuse it correctly, which is exactly what
106
+ `tests/unit/weft_agent/test_command_tools.py::
107
+ test_a_destroy_class_command_is_refused_even_if_a_tool_exists_for_it` builds to prove the two
108
+ halves — catalogue and consent — fail independently.
109
+ """
110
+
111
+ def __init__(self, *, registry: Registry, command_name: str) -> None:
112
+ self._registry = registry
113
+ self._command_name = command_name
114
+ entry = registry.entry(Command, command_name)
115
+ factory = unwrap_factory(entry.factory)
116
+ self.description = help_of(factory, command_name)
117
+
118
+ async def call(self, arguments: Mapping[str, object], ctx: Context) -> str:
119
+ """Build this command's `args_model` from `arguments`, then run it through `invoke`.
120
+
121
+ Never calls `instance.run(...)` — see this module's own docstring, property *(a)*, and
122
+ `test_the_tool_runs_a_command_only_through_the_seam`, which asserts this over the
123
+ module's source rather than over one call at runtime. `ConsentRefusedError` and any
124
+ other `WeftError` the command itself raises are caught here and returned as the
125
+ observation, per this pack's own reliability decision (`weft_agent.loop`'s own
126
+ docstring): a tool returns a string, and the model reads the refusal on its next turn
127
+ rather than the run ending underneath it. `CancelledError` is not caught and propagates
128
+ untouched.
129
+ """
130
+ entry = self._registry.entry(Command, self._command_name)
131
+ instance = cast(Command, entry.factory(None))
132
+ args_instance = instance.args_model(**dict(arguments))
133
+
134
+ try:
135
+ outcome = await invoke(
136
+ command_name=self._command_name,
137
+ instance=instance,
138
+ args=args_instance,
139
+ ctx=ctx,
140
+ consent=RefusingConsent(),
141
+ distribution=entry.distribution,
142
+ )
143
+ except ConsentRefusedError as error:
144
+ return str(error)
145
+ except WeftError as error:
146
+ return str(error)
147
+
148
+ return _observation_of(outcome)
149
+
150
+
151
+ def command_tools(registry: Registry) -> Mapping[str, CommandTool]:
152
+ """One `CommandTool` per entry `weft_agent.tools.tool_catalogue` offers, keyed identically.
153
+
154
+ The catalogue already applied G12's ceiling (`REACHABLE_CLASSES`) and `EXCLUDED_BY_NAME`; this
155
+ only wraps each surviving name in the tool the loop can actually call. `RefusingConsent` is
156
+ the belt to the catalogue's braces — see this module's own docstring, property *(c)*, and
157
+ `CommandTool`'s own docstring for why the two are tested independently.
158
+ """
159
+ return {
160
+ name: CommandTool(registry=registry, command_name=name) for name in tool_catalogue(registry)
161
+ }
162
+
163
+
164
+ __all__ = ["CommandTool", "ConsentRefusedError", "RefusingConsent", "command_tools"]
weft_agent/loop.py ADDED
@@ -0,0 +1,214 @@
1
+ """The agent's ReAct loop — task **7.2**. See this package's own `__init__.py` and
2
+ `docs/01-high-level-plan.md` -> Phase 7 for why the loop takes the shape it does; the reasoning
3
+ lives there and in `tests/unit/weft_agent/test_loop.py`'s own module docstring, not here twice.
4
+
5
+ **One step is: render the request, ask `weft_prompts.cascade.execute`, act on what came back.**
6
+ `NextActionRequest.tools` and `.transcript` arrive pre-rendered per that payload's own
7
+ docstring — turning the tool catalogue and the transcript into text is this module's job, since
8
+ `string.Template` cannot iterate a tuple.
9
+
10
+ **A tool name the model invented is refused as an observation, not an exception.** The model is
11
+ an untrusted source of names here exactly as a pipeline document is (requirement 5), but unlike a
12
+ document, the reader of the refusal is the model itself on its next turn — a wrong guess is
13
+ something an agent should be able to recover from, so the refusal is fed back rather than ending
14
+ the run.
15
+
16
+ **A cascade call that does not produce ends the loop with what was recorded so far, and says
17
+ `NO_DECISION`.** This branch is the one place task 7.2's own brief had decided nothing: it forbade
18
+ adding a `StopReason` member *and* forbade reusing `BUDGET_EXHAUSTED` for anything but the budget,
19
+ which are two constraints with no true value between them. The first pass reused
20
+ `BUDGET_EXHAUSTED`, documented the reuse and reported the contradiction rather than resolving it
21
+ quietly — which is the right handling of a brief that has decided nothing, and is why it was caught.
22
+
23
+ It is settled against the **operator** rather than against the code. `stopped_because` is a field
24
+ somebody reads to decide what to do next; `BUDGET_EXHAUSTED` on a run that stopped after one step
25
+ of ten tells them to raise the budget, and raising it would change nothing, because the model never
26
+ returned a usable decision. The remedy for this branch is the model or the prompt. Two causes
27
+ wearing one name is not wrong anywhere and ambiguous everywhere.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Mapping
33
+ from enum import StrEnum
34
+ from typing import Protocol, cast, runtime_checkable
35
+
36
+ from pydantic import BaseModel, ConfigDict
37
+
38
+ from weft_agent.payload import AgentStep, AgentTranscript, NextAction, NextActionRequest
39
+ from weft_agent.prompts import NextActionPrompt
40
+ from weft_kernel.context import Context
41
+ from weft_kernel.errors import WeftError
42
+ from weft_kernel.payload import Produced
43
+ from weft_llm.contract import LLM
44
+ from weft_prompts.cascade import execute
45
+ from weft_prompts.contract import Prompt
46
+
47
+
48
+ @runtime_checkable
49
+ class AgentTool(Protocol):
50
+ """One tool the loop may offer a model: a name to call it under, a description to pick it
51
+ by, and the call itself.
52
+
53
+ The name a tool is offered under is the key the caller's `tools` mapping supplies, never a
54
+ member on the tool — a tool run under two names (a catalogue's alias, say) needs no second
55
+ implementation.
56
+ """
57
+
58
+ #: Not a `ClassVar`. A tool whose description is *derived* — every `CommandTool`'s is, being
59
+ #: the command's own mandatory `help` — sets this per instance, and declaring it a `ClassVar`
60
+ #: here would make that a type error while working perfectly at runtime. The Protocol states
61
+ #: what a tool must *have*, never how the implementer must spell it.
62
+ description: str
63
+
64
+ async def call(self, arguments: Mapping[str, object], ctx: Context) -> str: ...
65
+
66
+
67
+ class UndecidedActionError(WeftError):
68
+ """A `NextAction` carrying neither a call nor a final answer reached the loop.
69
+
70
+ Unreachable through `NextAction`'s own validator, and named rather than asserted so that it
71
+ stays a refusal in an optimised build — see the raise site.
72
+ """
73
+
74
+
75
+ class StopReason(StrEnum):
76
+ """Why `run_agent` returned — a field somebody reads to decide what to do next.
77
+
78
+ **Three members, and the third was added rather than folded into the second on purpose.**
79
+ Task 7.2's brief forbade adding a member *and* forbade reusing `BUDGET_EXHAUSTED` for
80
+ anything but the budget, which left no value that is true; the implementer reused it,
81
+ documented the reuse and reported the contradiction rather than choosing quietly. Settled
82
+ against the operator: `BUDGET_EXHAUSTED` on a run that stopped after one step of ten tells
83
+ them to raise the budget, and raising it would change nothing, because the model never
84
+ returned a usable decision. Two causes wearing one name is not wrong anywhere and ambiguous
85
+ everywhere.
86
+ """
87
+
88
+ #: The model gave a final answer. The only member that comes with one.
89
+ ANSWERED = "answered"
90
+ #: Every one of `max_steps` steps was taken and none of them answered.
91
+ BUDGET_EXHAUSTED = "budget_exhausted"
92
+ #: The cascade could not turn what the model said into a `NextAction` at any tier — so the
93
+ #: run stopped with steps left, and the remedy is the model or the prompt, never the budget.
94
+ NO_DECISION = "no_decision"
95
+
96
+
97
+ class AgentOutcome(BaseModel):
98
+ """What a run produced: the full transcript, the final answer if there is one, and why the
99
+ loop stopped."""
100
+
101
+ model_config = ConfigDict(frozen=True, extra="forbid")
102
+
103
+ transcript: AgentTranscript
104
+ final_answer: str | None
105
+ stopped_because: StopReason
106
+
107
+
108
+ def _render_tools(tools: Mapping[str, AgentTool]) -> str:
109
+ """The tool catalogue as the numbered list `next-action`'s prompt text asks for."""
110
+ if not tools:
111
+ return "(no tools are available; you must answer directly)"
112
+ return "\n".join(f"- {name}: {tool.description}" for name, tool in tools.items())
113
+
114
+
115
+ def _render_transcript(transcript: AgentTranscript) -> str:
116
+ """Every step recorded so far, as text a model reads back on its next turn."""
117
+ if not transcript.steps:
118
+ return "(nothing yet — this is the first step)"
119
+ lines: list[str] = []
120
+ for index, step in enumerate(transcript.steps, start=1):
121
+ action = step.action
122
+ lines.append(f"{index}. reasoning: {action.reasoning}")
123
+ if action.call is not None:
124
+ lines.append(f" called '{action.call.tool}' with {dict(action.call.arguments)!r}")
125
+ lines.append(f" observed: {step.observation}")
126
+ else:
127
+ lines.append(f" answered: {action.final_answer}")
128
+ return "\n".join(lines)
129
+
130
+
131
+ def _refuse_unknown_tool(requested: str, tools: Mapping[str, AgentTool]) -> str:
132
+ """Requirement 5, phrased for the model rather than for a log: what was asked for, and
133
+ what is actually on offer."""
134
+ available = ", ".join(sorted(tools)) if tools else "(none)"
135
+ return (
136
+ f"tool '{requested}' is not available. Available tools: {available}. "
137
+ "Choose one of the available tools, or answer if you already have enough."
138
+ )
139
+
140
+
141
+ async def run_agent(
142
+ *,
143
+ goal: str,
144
+ tools: Mapping[str, AgentTool],
145
+ llm: LLM,
146
+ ctx: Context,
147
+ max_steps: int,
148
+ ) -> AgentOutcome:
149
+ """Run one ReAct loop toward `goal`, for at most `max_steps` steps.
150
+
151
+ Never raises for budget exhaustion or for a cascade that could not produce a decision — both
152
+ are typed results, per this pack's own reliability decision. `CancelledError` is not caught
153
+ anywhere here and propagates untouched, the same as everywhere else in this tree.
154
+ """
155
+ transcript = AgentTranscript(goal=goal)
156
+ rendered_tools = _render_tools(tools)
157
+
158
+ for _ in range(max_steps):
159
+ request = NextActionRequest(
160
+ goal=goal, tools=rendered_tools, transcript=_render_transcript(transcript)
161
+ )
162
+ outcome = await execute(
163
+ llm=llm,
164
+ # `NextActionPrompt` satisfies `Prompt` structurally at runtime — `version` is
165
+ # assigned dynamically the same way every registered prompt's is, per
166
+ # `weft_prompts.contract.Prompt`'s own docstring — but is invisible to a static
167
+ # check on a concrete class. `weft_prompts.registry.PromptRegistry.render` casts
168
+ # for the identical reason; there is no tool catalogue to resolve this through yet.
169
+ prompt=cast(Prompt, NextActionPrompt()),
170
+ values=request,
171
+ output=NextAction,
172
+ role="agent",
173
+ ctx=ctx,
174
+ )
175
+ if not isinstance(outcome, Produced):
176
+ # The model said something the cascade could not read as a decision at any of its
177
+ # three tiers. The run stops with budget left, and says so — see `StopReason`.
178
+ return AgentOutcome(
179
+ transcript=transcript,
180
+ final_answer=None,
181
+ stopped_because=StopReason.NO_DECISION,
182
+ )
183
+ action = outcome.value.value
184
+
185
+ if action.final_answer is not None:
186
+ transcript = transcript.with_step(AgentStep(action=action))
187
+ return AgentOutcome(
188
+ transcript=transcript,
189
+ final_answer=action.final_answer,
190
+ stopped_because=StopReason.ANSWERED,
191
+ )
192
+
193
+ # `NextAction`'s own validator guarantees exactly one of `call`/`final_answer` is set, and
194
+ # `final_answer` was ruled out above — so this is unreachable. It is a **named refusal
195
+ # rather than an `assert`**: an assert is stripped under `-O`, which would leave the line
196
+ # below dereferencing `None` in exactly the build where nobody is watching
197
+ # (`docs/lessons.md` `L8.37`).
198
+ call = action.call
199
+ if call is None: # pragma: no cover - the validator makes this unreachable
200
+ raise UndecidedActionError(
201
+ f"a NextAction reached the loop with neither a call nor a final answer, which "
202
+ f"NextAction's own validator forbids: {action!r}"
203
+ )
204
+
205
+ tool = tools.get(call.tool)
206
+ if tool is None:
207
+ observation = _refuse_unknown_tool(call.tool, tools)
208
+ else:
209
+ observation = await tool.call(call.arguments, ctx)
210
+ transcript = transcript.with_step(AgentStep(action=action, observation=observation))
211
+
212
+ return AgentOutcome(
213
+ transcript=transcript, final_answer=None, stopped_because=StopReason.BUDGET_EXHAUSTED
214
+ )