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.
- weft_agent/__init__.py +69 -0
- weft_agent/command.py +147 -0
- weft_agent/command_tools.py +164 -0
- weft_agent/loop.py +214 -0
- weft_agent/payload.py +110 -0
- weft_agent/prompts.py +55 -0
- weft_agent/py.typed +0 -0
- weft_agent/render.py +53 -0
- weft_agent/tools.py +120 -0
- weft_blob/__init__.py +106 -0
- weft_blob/contract.py +76 -0
- weft_blob/filesystem_store.py +241 -0
- weft_blob/keys.py +81 -0
- weft_blob/payload.py +32 -0
- weft_blob/py.typed +0 -0
- weft_chunk/__init__.py +61 -0
- weft_chunk/carry.py +35 -0
- weft_chunk/contract.py +81 -0
- weft_chunk/fixed_size.py +161 -0
- weft_chunk/payload.py +34 -0
- weft_chunk/property.py +27 -0
- weft_chunk/py.typed +0 -0
- weft_chunk/table_rows.py +134 -0
- weft_clean/__init__.py +75 -0
- weft_clean/artifact_remover.py +136 -0
- weft_clean/contract.py +78 -0
- weft_clean/dictionary_spacing.py +145 -0
- weft_clean/hyphenation.py +98 -0
- weft_clean/language.py +36 -0
- weft_clean/property.py +77 -0
- weft_clean/py.typed +0 -0
- weft_clean/table_linearizer.py +116 -0
- weft_clean/unicode_normalizer.py +90 -0
- weft_clean/whitespace.py +94 -0
- weft_cli/__init__.py +5 -0
- weft_cli/answer_envelope.py +112 -0
- weft_cli/argparse_gen.py +150 -0
- weft_cli/ask.py +238 -0
- weft_cli/cli.py +848 -0
- weft_cli/command_table.py +130 -0
- weft_cli/commands.py +1383 -0
- weft_cli/compile.py +424 -0
- weft_cli/config_commands.py +175 -0
- weft_cli/config_surface.py +431 -0
- weft_cli/confirm.py +214 -0
- weft_cli/contract_reference.py +623 -0
- weft_cli/deletion.py +148 -0
- weft_cli/error_envelope.py +117 -0
- weft_cli/eval_commands.py +1132 -0
- weft_cli/eval_scoring.py +357 -0
- weft_cli/exit_codes.py +175 -0
- weft_cli/fanout.py +151 -0
- weft_cli/ingest.py +1087 -0
- weft_cli/installed_versions.py +47 -0
- weft_cli/llm_roles.py +228 -0
- weft_cli/output.py +33 -0
- weft_cli/pack_attribution.py +299 -0
- weft_cli/participation.py +175 -0
- weft_cli/permission_policy.py +144 -0
- weft_cli/pipeline_catalogue.py +324 -0
- weft_cli/pipeline_commands.py +439 -0
- weft_cli/pipeline_diff.py +149 -0
- weft_cli/plugins_report.py +362 -0
- weft_cli/preview.py +223 -0
- weft_cli/py.typed +0 -0
- weft_cli/reconcile.py +215 -0
- weft_cli/reconcile_policy.py +114 -0
- weft_cli/registry_bootstrap.py +616 -0
- weft_cli/render.py +1128 -0
- weft_cli/repl.py +425 -0
- weft_cli/route_ask.py +669 -0
- weft_cli/run_services.py +994 -0
- weft_cli/service_roles.py +98 -0
- weft_cli/services.py +276 -0
- weft_cli/session.py +145 -0
- weft_cli/sinks.py +300 -0
- weft_cli/skew.py +154 -0
- weft_cli/tracing_status.py +48 -0
- weft_command/__init__.py +47 -0
- weft_command/catalogue.py +97 -0
- weft_command/contract.py +221 -0
- weft_command/invocation.py +85 -0
- weft_command/permission.py +44 -0
- weft_command/py.typed +0 -0
- weft_command/render.py +67 -0
- weft_docling/__init__.py +107 -0
- weft_docling/pdf_layout_model.py +230 -0
- weft_docling/pipelines/index-pdf-learned.yaml +48 -0
- weft_docling/py.typed +0 -0
- weft_docling/weights.py +66 -0
- weft_embed/__init__.py +52 -0
- weft_embed/contract.py +74 -0
- weft_embed/hash_embedder.py +100 -0
- weft_embed/py.typed +0 -0
- weft_enhance/__init__.py +47 -0
- weft_enhance/contract.py +86 -0
- weft_enhance/keybert_stand_in.py +144 -0
- weft_enhance/keywords.py +19 -0
- weft_enhance/py.typed +0 -0
- weft_eval/__init__.py +316 -0
- weft_eval/aggregate.py +306 -0
- weft_eval/at_threshold.py +79 -0
- weft_eval/contract.py +263 -0
- weft_eval/embedding_metrics.py +124 -0
- weft_eval/embedding_support.py +55 -0
- weft_eval/falsify.py +288 -0
- weft_eval/harness.py +180 -0
- weft_eval/ir_metrics.py +163 -0
- weft_eval/judges.py +432 -0
- weft_eval/lexical.py +256 -0
- weft_eval/offline.py +155 -0
- weft_eval/pricing.py +150 -0
- weft_eval/prompts.py +353 -0
- weft_eval/py.typed +0 -0
- weft_eval/qa_metrics.py +130 -0
- weft_eval/run_record.py +249 -0
- weft_extract/__init__.py +101 -0
- weft_extract/accept.py +70 -0
- weft_extract/contract.py +163 -0
- weft_extract/payload.py +210 -0
- weft_extract/py.typed +0 -0
- weft_extract/render.py +162 -0
- weft_extract/table_text.py +98 -0
- weft_extract/text.py +118 -0
- weft_generate/__init__.py +115 -0
- weft_generate/cited_answer.py +274 -0
- weft_generate/contract.py +58 -0
- weft_generate/contradiction.py +294 -0
- weft_generate/page.py +61 -0
- weft_generate/payload.py +148 -0
- weft_generate/prompts.py +279 -0
- weft_generate/py.typed +0 -0
- weft_generate/refine.py +399 -0
- weft_generate/representation.py +72 -0
- weft_index/__init__.py +102 -0
- weft_index/adrap.py +638 -0
- weft_index/contract.py +141 -0
- weft_index/hypothetical_questions.py +163 -0
- weft_index/payload.py +170 -0
- weft_index/prompts.py +157 -0
- weft_index/py.typed +0 -0
- weft_index/raptor.py +1122 -0
- weft_kg/__init__.py +255 -0
- weft_kg/adjudication.py +123 -0
- weft_kg/atomicity.py +121 -0
- weft_kg/bridges.py +267 -0
- weft_kg/commands.py +474 -0
- weft_kg/contract.py +168 -0
- weft_kg/cooccurrence.py +83 -0
- weft_kg/extraction.py +371 -0
- weft_kg/names.py +117 -0
- weft_kg/payload.py +229 -0
- weft_kg/pipelines/graph-2hop-then-generate.yaml +26 -0
- weft_kg/pipelines/graph-and-vector-rrf.yaml +38 -0
- weft_kg/pipelines/graph-then-generate.yaml +28 -0
- weft_kg/pipelines/graph-then-rerank.yaml +25 -0
- weft_kg/pipelines/index-with-cooccurrence.yaml +15 -0
- weft_kg/pipelines/index-with-facts-openai.yaml +47 -0
- weft_kg/pipelines/index-with-facts.yaml +19 -0
- weft_kg/pipelines/index-with-graph.yaml +20 -0
- weft_kg/prompts.py +355 -0
- weft_kg/py.typed +0 -0
- weft_kg/resolution.py +360 -0
- weft_kg/retrieval.py +176 -0
- weft_kg/schema.py +315 -0
- weft_kg/store.py +1742 -0
- weft_kg/traversal.py +221 -0
- weft_llm/__init__.py +91 -0
- weft_llm/client.py +318 -0
- weft_llm/contract.py +158 -0
- weft_llm/errors.py +229 -0
- weft_llm/loop_guard.py +238 -0
- weft_llm/models.py +156 -0
- weft_llm/payload.py +169 -0
- weft_llm/py.typed +0 -0
- weft_llm/retry.py +179 -0
- weft_llm/roles.py +83 -0
- weft_llm/scripted.py +99 -0
- weft_openai/__init__.py +144 -0
- weft_openai/embedder.py +362 -0
- weft_openai/llm.py +434 -0
- weft_openai/py.typed +0 -0
- weft_openai/settings.py +54 -0
- weft_openai/vision.py +235 -0
- weft_otel/__init__.py +107 -0
- weft_otel/provider.py +100 -0
- weft_otel/py.typed +0 -0
- weft_otel/settings.py +88 -0
- weft_pdf/__init__.py +79 -0
- weft_pdf/document.py +567 -0
- weft_pdf/pdf_layout.py +384 -0
- weft_pdf/pdf_text.py +314 -0
- weft_pdf/py.typed +0 -0
- weft_prompts/__init__.py +61 -0
- weft_prompts/cascade.py +231 -0
- weft_prompts/contract.py +80 -0
- weft_prompts/errors.py +58 -0
- weft_prompts/py.typed +0 -0
- weft_prompts/registry.py +73 -0
- weft_prompts/rescue.py +65 -0
- weft_prompts/template.py +84 -0
- weft_prompts/typed_prompt.py +121 -0
- weft_qdrant/__init__.py +70 -0
- weft_qdrant/py.typed +0 -0
- weft_qdrant/settings.py +54 -0
- weft_qdrant/store.py +688 -0
- weft_rag-2.4.0.dist-info/METADATA +108 -0
- weft_rag-2.4.0.dist-info/RECORD +282 -0
- weft_rag-2.4.0.dist-info/WHEEL +4 -0
- weft_rag-2.4.0.dist-info/entry_points.txt +25 -0
- weft_rag-2.4.0.dist-info/licenses/LICENSE +21 -0
- weft_rag-2.4.0.dist-info/licenses/NOTICE +77 -0
- weft_retrieve/__init__.py +666 -0
- weft_retrieve/boolean.py +461 -0
- weft_retrieve/collapse.py +283 -0
- weft_retrieve/contract.py +302 -0
- weft_retrieve/corrective.py +263 -0
- weft_retrieve/engine.py +223 -0
- weft_retrieve/fusion.py +616 -0
- weft_retrieve/graded.py +228 -0
- weft_retrieve/hybrid.py +221 -0
- weft_retrieve/iterative.py +422 -0
- weft_retrieve/multi_arm.py +145 -0
- weft_retrieve/multi_retriever.py +164 -0
- weft_retrieve/no_retrieval.py +62 -0
- weft_retrieve/payload.py +441 -0
- weft_retrieve/pipelines/boolean-then-retrieve.yaml +29 -0
- weft_retrieve/pipelines/broad-and-refined-rrf.yaml +66 -0
- weft_retrieve/pipelines/contradiction-aware.yaml +24 -0
- weft_retrieve/pipelines/corrective-retrieve.yaml +56 -0
- weft_retrieve/pipelines/draft-then-refine.yaml +31 -0
- weft_retrieve/pipelines/grade-then-generate.yaml +24 -0
- weft_retrieve/pipelines/hybrid-then-generate.yaml +49 -0
- weft_retrieve/pipelines/hyde-then-retrieve.yaml +27 -0
- weft_retrieve/pipelines/index-messy-text.yaml +36 -0
- weft_retrieve/pipelines/index-openai.yaml +32 -0
- weft_retrieve/pipelines/index-pdf-described.yaml +21 -0
- weft_retrieve/pipelines/index-pdf-rows.yaml +22 -0
- weft_retrieve/pipelines/index-pdf-text.yaml +20 -0
- weft_retrieve/pipelines/index-pdf-undescribed.yaml +12 -0
- weft_retrieve/pipelines/index-pdf.yaml +27 -0
- weft_retrieve/pipelines/index-polish.yaml +21 -0
- weft_retrieve/pipelines/index-qdrant.yaml +21 -0
- weft_retrieve/pipelines/index-text.yaml +64 -0
- weft_retrieve/pipelines/index-with-adrap.yaml +42 -0
- weft_retrieve/pipelines/index-with-deep-raptor.yaml +56 -0
- weft_retrieve/pipelines/index-with-keywords.yaml +15 -0
- weft_retrieve/pipelines/index-with-questions.yaml +23 -0
- weft_retrieve/pipelines/index-with-raptor.yaml +95 -0
- weft_retrieve/pipelines/iterative-retrieve.yaml +32 -0
- weft_retrieve/pipelines/multi-query-then-retrieve.yaml +27 -0
- weft_retrieve/pipelines/no-retrieval.yaml +9 -0
- weft_retrieve/pipelines/preview-markdown.yaml +18 -0
- weft_retrieve/pipelines/preview-plain.yaml +25 -0
- weft_retrieve/pipelines/raptor-and-leaves-rrf.yaml +78 -0
- weft_retrieve/pipelines/rerank-then-generate.yaml +24 -0
- weft_retrieve/pipelines/retrieve-then-generate.yaml +9 -0
- weft_retrieve/pipelines/rewrite-then-retrieve.yaml +25 -0
- weft_retrieve/pipelines/route-by-score.yaml +24 -0
- weft_retrieve/pipelines/route-fixed.yaml +23 -0
- weft_retrieve/pipelines/route.yaml +4 -0
- weft_retrieve/pipelines/step-back-then-retrieve.yaml +22 -0
- weft_retrieve/pipelines/summarise-then-generate.yaml +45 -0
- weft_retrieve/postqfrap.py +372 -0
- weft_retrieve/prompts.py +1090 -0
- weft_retrieve/py.typed +0 -0
- weft_retrieve/repack.py +171 -0
- weft_retrieve/rerank.py +216 -0
- weft_retrieve/routing.py +682 -0
- weft_retrieve/sufficiency.py +262 -0
- weft_retrieve/transforms.py +742 -0
- weft_retrieve/vector_top_k.py +280 -0
- weft_store/__init__.py +152 -0
- weft_store/contract.py +964 -0
- weft_store/fields.py +249 -0
- weft_store/pgvector_store.py +1105 -0
- weft_store/py.typed +0 -0
- weft_store/rehydrate.py +178 -0
- weft_vision/__init__.py +66 -0
- weft_vision/contract.py +69 -0
- weft_vision/describe_figure.py +203 -0
- 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
|
+
)
|