render-lab-tasks-agent 0.1.0__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.
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ *.egg-info/
9
+ .env
10
+ .env.*
11
+ !.env.example
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Render Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.5
2
+ Name: render-lab-tasks-agent
3
+ Version: 0.1.0
4
+ Summary: agent tasks for Render Workflows
5
+ Project-URL: Repository, https://github.com/render-lab/render-tasks-python
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: httpx<0.29,>=0.28
10
+ Requires-Dist: render-lab-tasks-core<0.2,>=0.1.1
11
+ Requires-Dist: render-lab-tasks-llm<0.2,>=0.1.1
12
+ Requires-Dist: render==1.0.1
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Agent tasks for Python
16
+
17
+ Eight registered tasks cover planning, single decisions, loops, reflection,
18
+ routing, history compression, named tools, and injected KV memory.
19
+ Import the tasks and `app` from `render_lab_tasks_agent.tasks`.
20
+
21
+ ## Composition and environment
22
+
23
+ LLM calls inherit the `render-lab-tasks-llm` provider/model/ledger environment
24
+ contract. Credentials remain lazy. Raw decision implementations accept
25
+ `CompletionDeps(complete=...)`; loops take `AgentDeps(step=..., tools=...)`.
26
+ The default loop dispatches wrapped `agent.step` through `ctx.run` so every
27
+ model decision is a durable child run. Register `ToolBinding` values with
28
+ `register_agent_tool`; each binding's `run(ctx, args)` must dispatch wrapped
29
+ Render tasks via `ctx.run` and return a string observation.
30
+
31
+ `memory_impl` requires an injected `MemoryDeps(kv=...)`; there is no implicit
32
+ in-memory fallback or default KV account. Re-register a wrapper under your own
33
+ namespace to supply the store. No live agent/provider run verified this port.
34
+
35
+ ## Installation
36
+
37
+ ```sh
38
+ pip install render-lab-tasks-agent==0.1.0
39
+ ```
@@ -0,0 +1,25 @@
1
+ # Agent tasks for Python
2
+
3
+ Eight registered tasks cover planning, single decisions, loops, reflection,
4
+ routing, history compression, named tools, and injected KV memory.
5
+ Import the tasks and `app` from `render_lab_tasks_agent.tasks`.
6
+
7
+ ## Composition and environment
8
+
9
+ LLM calls inherit the `render-lab-tasks-llm` provider/model/ledger environment
10
+ contract. Credentials remain lazy. Raw decision implementations accept
11
+ `CompletionDeps(complete=...)`; loops take `AgentDeps(step=..., tools=...)`.
12
+ The default loop dispatches wrapped `agent.step` through `ctx.run` so every
13
+ model decision is a durable child run. Register `ToolBinding` values with
14
+ `register_agent_tool`; each binding's `run(ctx, args)` must dispatch wrapped
15
+ Render tasks via `ctx.run` and return a string observation.
16
+
17
+ `memory_impl` requires an injected `MemoryDeps(kv=...)`; there is no implicit
18
+ in-memory fallback or default KV account. Re-register a wrapper under your own
19
+ namespace to supply the store. No live agent/provider run verified this port.
20
+
21
+ ## Installation
22
+
23
+ ```sh
24
+ pip install render-lab-tasks-agent==0.1.0
25
+ ```
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "render-lab-tasks-agent"
7
+ version = "0.1.0"
8
+ description = "agent tasks for Render Workflows"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ dependencies = ["render-lab-tasks-llm>=0.1.1,<0.2","render==1.0.1", "httpx>=0.28,<0.29", "render-lab-tasks-core>=0.1.1,<0.2"]
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/render-lab/render-tasks-python"
17
+
18
+ [tool.uv.sources]
19
+ render-lab-tasks-llm = { workspace = true }
20
+ render-lab-tasks-core = { workspace = true }
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/render_lab_tasks_agent"]
@@ -0,0 +1 @@
1
+ """Import .tasks explicitly to register durable tasks."""
@@ -0,0 +1,3 @@
1
+ from render import Workflows
2
+
3
+ app = Workflows()
@@ -0,0 +1,64 @@
1
+ """Injected completions and a registry of durable tool bindings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass, field
7
+
8
+ from render import TaskContext
9
+ from render_lab_tasks_llm.types import CompleteInput, CompleteResult
10
+
11
+ from .types import AgentTool, Json, StepInput, StepResult
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ToolBinding:
16
+ spec: AgentTool
17
+ run: Callable[[TaskContext, dict[str, Json]], Awaitable[str]]
18
+
19
+
20
+ tool_registry: dict[str, ToolBinding] = {}
21
+
22
+
23
+ def register_agent_tool(binding: ToolBinding) -> None:
24
+ tool_registry[binding.spec["name"]] = binding
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CompletionDeps:
29
+ complete: Callable[[CompleteInput], Awaitable[CompleteResult]]
30
+
31
+
32
+ def completion_deps(ctx: TaskContext, deps: CompletionDeps | None) -> CompletionDeps:
33
+ if deps is not None:
34
+ return deps
35
+ from render_lab_tasks_llm.complete import complete_impl
36
+
37
+ async def complete(input: CompleteInput) -> CompleteResult:
38
+ return await complete_impl(ctx, input)
39
+
40
+ return CompletionDeps(complete=complete)
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class AgentDeps:
45
+ step: Callable[[StepInput], Awaitable[StepResult]]
46
+ tools: dict[str, ToolBinding] = field(default_factory=lambda: tool_registry)
47
+
48
+
49
+ def agent_deps(ctx: TaskContext, deps: AgentDeps | None) -> AgentDeps:
50
+ if deps is not None:
51
+ return deps
52
+ from .step import step
53
+
54
+ async def decide(input: StepInput) -> StepResult:
55
+ return await ctx.run(step, input)
56
+
57
+ return AgentDeps(step=decide)
58
+
59
+
60
+ PlanDeps = CompletionDeps
61
+ StepDeps = CompletionDeps
62
+ ReflectDeps = CompletionDeps
63
+ RouteDeps = CompletionDeps
64
+ CompressHistoryDeps = CompletionDeps
@@ -0,0 +1,34 @@
1
+ from render import TaskContext
2
+ from render_lab_tasks_llm.chat import resolve_model
3
+ from render_lab_tasks_llm.types import CompleteInput
4
+
5
+ from . import types as t
6
+ from ._app import app
7
+ from .client import CompletionDeps, completion_deps
8
+ from .prompts import build_compress_prompt
9
+ from .retry import AGENT_RETRY
10
+
11
+
12
+ async def compress_history_impl(
13
+ ctx: TaskContext, input: t.CompressHistoryInput, *, deps: CompletionDeps | None = None
14
+ ) -> t.CompressHistoryResult:
15
+ if not input["history"]:
16
+ return {"summary": ""}
17
+ resolved = completion_deps(ctx, deps)
18
+ system, prompt = build_compress_prompt(input)
19
+ request: CompleteInput = {
20
+ "prompt": prompt,
21
+ "system": system,
22
+ "model": resolve_model(input.get("model")),
23
+ }
24
+ if "ledger" in input:
25
+ request["ledger"] = input["ledger"]
26
+ response = await resolved.complete(request)
27
+ return {"summary": response["text"].strip()}
28
+
29
+
30
+ @app.task(name="agent.compressHistory", retry=AGENT_RETRY)
31
+ async def compress_history(
32
+ ctx: TaskContext, input: t.CompressHistoryInput
33
+ ) -> t.CompressHistoryResult:
34
+ return await compress_history_impl(ctx, input)
@@ -0,0 +1,64 @@
1
+ from render import TaskContext
2
+ from render_lab_tasks_core.http import nullish
3
+
4
+ from ._app import app
5
+ from .client import AgentDeps, agent_deps
6
+ from .retry import AGENT_RETRY
7
+ from .types import AgentStepRecord, LoopInput, LoopResult, StepInput
8
+
9
+
10
+ async def loop_impl(
11
+ ctx: TaskContext, input: LoopInput, *, deps: AgentDeps | None = None
12
+ ) -> LoopResult:
13
+ resolved = agent_deps(ctx, deps)
14
+ tools = (
15
+ {name: resolved.tools[name] for name in input["toolNames"] if name in resolved.tools}
16
+ if input.get("toolNames") is not None
17
+ else resolved.tools
18
+ )
19
+ specs = [binding.spec for binding in tools.values()]
20
+ history: list[AgentStepRecord] = []
21
+ last = ""
22
+ i = 0
23
+ while i < nullish(input.get("maxSteps"), 10):
24
+ i += 1
25
+ request: StepInput = {"goal": input["goal"], "history": history.copy(), "tools": specs}
26
+ if "system" in input:
27
+ request["system"] = input["system"]
28
+ if "model" in input:
29
+ request["model"] = input["model"]
30
+ if "ledger" in input:
31
+ request["ledger"] = input["ledger"]
32
+ action = await resolved.step(request)
33
+ if action["type"] == "final":
34
+ return {
35
+ "answer": nullish(action.get("answer"), ""),
36
+ "steps": history,
37
+ "stepCount": len(history),
38
+ "stoppedReason": "final",
39
+ }
40
+ name = nullish(action.get("tool"), "")
41
+ record: AgentStepRecord = {"tool": name}
42
+ if "thought" in action:
43
+ record["thought"] = action["thought"]
44
+ if "args" in action:
45
+ record["args"] = action["args"]
46
+ if name not in tools:
47
+ record["observation"] = f'error: unknown tool "{name}"'
48
+ else:
49
+ last = await tools[name].run(ctx, nullish(action.get("args"), {}))
50
+ if not isinstance(last, str):
51
+ raise TypeError("Agent tools must return a string observation")
52
+ record["observation"] = last
53
+ history.append(record)
54
+ return {
55
+ "answer": last,
56
+ "steps": history,
57
+ "stepCount": len(history),
58
+ "stoppedReason": "maxSteps",
59
+ }
60
+
61
+
62
+ @app.task(name="agent.loop", retry=AGENT_RETRY)
63
+ async def loop(ctx: TaskContext, input: LoopInput) -> LoopResult:
64
+ return await loop_impl(ctx, input)
@@ -0,0 +1,41 @@
1
+ from dataclasses import dataclass
2
+ from typing import Protocol
3
+
4
+ from render import TaskContext
5
+
6
+ from ._app import app
7
+ from .retry import AGENT_RETRY
8
+ from .types import MemoryInput, MemoryResult
9
+
10
+
11
+ class MemoryKvPort(Protocol):
12
+ async def get(self, key: str) -> str | None: ...
13
+ async def set(self, key: str, value: str) -> None: ...
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class MemoryDeps:
18
+ kv: MemoryKvPort | None = None
19
+
20
+
21
+ async def memory_impl(
22
+ ctx: TaskContext, input: MemoryInput, *, deps: MemoryDeps | None = None
23
+ ) -> MemoryResult:
24
+ if deps is None or deps.kv is None:
25
+ raise ValueError(
26
+ "agent.memory: no KV store configured — inject deps.kv (e.g. from "
27
+ "@render-lab/tasks-render-kv) with { get(key), set(key, value) }."
28
+ )
29
+ if input["op"] == "get":
30
+ return {"key": input["key"], "value": await deps.kv.get(input["key"])}
31
+ if input["op"] == "set":
32
+ if "value" not in input:
33
+ raise ValueError(f'agent.memory: "set" requires a value for key "{input["key"]}".')
34
+ await deps.kv.set(input["key"], input["value"])
35
+ return {"key": input["key"], "ok": True}
36
+ raise ValueError(f'agent.memory: unknown op "{input["op"]}" (expected "get" or "set").')
37
+
38
+
39
+ @app.task(name="agent.memory", retry=AGENT_RETRY)
40
+ async def memory(ctx: TaskContext, input: MemoryInput) -> MemoryResult:
41
+ return await memory_impl(ctx, input)
@@ -0,0 +1,37 @@
1
+ from render import TaskContext
2
+ from render_lab_tasks_core.http import js_string
3
+ from render_lab_tasks_llm.chat import resolve_model
4
+ from render_lab_tasks_llm.json import extract_json
5
+ from render_lab_tasks_llm.types import CompleteInput
6
+
7
+ from . import types as t
8
+ from ._app import app
9
+ from .client import CompletionDeps, completion_deps
10
+ from .prompts import build_plan_prompt
11
+ from .retry import AGENT_RETRY
12
+
13
+
14
+ async def plan_impl(
15
+ ctx: TaskContext, input: t.PlanInput, *, deps: CompletionDeps | None = None
16
+ ) -> t.PlanResult:
17
+ resolved = completion_deps(ctx, deps)
18
+ system, prompt = build_plan_prompt(input)
19
+ request: CompleteInput = {
20
+ "prompt": prompt,
21
+ "system": system,
22
+ "model": resolve_model(input.get("model")),
23
+ }
24
+ if "ledger" in input:
25
+ request["ledger"] = input["ledger"]
26
+ response = await resolved.complete(request)
27
+ parsed = extract_json(response["text"])
28
+ steps = parsed.get("steps") if isinstance(parsed, dict) else None
29
+ return {
30
+ "steps": [js_string(s) for s in steps] if isinstance(steps, list) else [],
31
+ "model": response["model"],
32
+ }
33
+
34
+
35
+ @app.task(name="agent.plan", retry=AGENT_RETRY)
36
+ async def plan(ctx: TaskContext, input: t.PlanInput) -> t.PlanResult:
37
+ return await plan_impl(ctx, input)
@@ -0,0 +1,140 @@
1
+ """Pure prompt builders for observable agent decisions."""
2
+
3
+ import json
4
+
5
+ from render_lab_tasks_core.http import js_string, nullish
6
+
7
+ from . import types as t
8
+
9
+
10
+ def compact(value: t.Json) -> str:
11
+ return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
12
+
13
+
14
+ def format_history(history: list[t.AgentStepRecord]) -> str:
15
+ if not history:
16
+ return "(none yet)"
17
+ lines = []
18
+ for i, item in enumerate(history):
19
+ parts = [f"{i + 1}."]
20
+ if item.get("thought"):
21
+ parts.append("thought: " + item["thought"])
22
+ if item.get("tool"):
23
+ parts.append(f"tool: {item['tool']}({compact(item.get('args', {}))})")
24
+ if "observation" in item:
25
+ parts.append("observation: " + item["observation"])
26
+ lines.append(" ".join(parts))
27
+ return "\n".join(lines)
28
+
29
+
30
+ def build_step_prompt(input: t.StepInput) -> tuple[str, str]:
31
+ tools = (
32
+ "\n".join(
33
+ f"- {tool['name']}: {tool['description']} (args JSON Schema: "
34
+ f"{compact(tool['parameters'])})"
35
+ for tool in input["tools"]
36
+ )
37
+ or "(no tools available)"
38
+ )
39
+ lines = [input["system"].strip()] if "system" in input else []
40
+ system = "\n".join(
41
+ lines
42
+ + [
43
+ "You are a ReAct agent. You accomplish a goal by choosing one action at a time,",
44
+ "observing the result, and repeating until you can answer.",
45
+ "",
46
+ "Available tools:",
47
+ tools,
48
+ "",
49
+ "Respond with ONLY a single JSON object. No prose, no explanation, no markdown fences.",
50
+ 'To call a tool: {"type":"tool","tool":"<name>","args":{ ... },"thought":"<why>"}',
51
+ 'To finish: {"type":"final","answer":"<the answer>","thought":"<why>"}',
52
+ 'The tool name MUST be one of the names listed above. Use type "final" '
53
+ "once you have enough information.",
54
+ ]
55
+ )
56
+ prompt = "\n".join(
57
+ [
58
+ f"Goal: {input['goal']}",
59
+ "",
60
+ "Steps so far:",
61
+ format_history(input["history"]),
62
+ "",
63
+ "What is your next action? Respond with ONLY the JSON object.",
64
+ ]
65
+ )
66
+ return system, prompt
67
+
68
+
69
+ def build_plan_prompt(input: t.PlanInput) -> tuple[str, str]:
70
+ system = "\n".join(
71
+ [
72
+ "You are a planning assistant. Break a goal into a short, ordered list of",
73
+ "concrete steps an agent could execute.",
74
+ "",
75
+ 'Respond with ONLY a JSON object of the form {"steps":["first step","second step"]}.',
76
+ "No prose, no markdown fences. Keep it to at most 6 steps.",
77
+ ]
78
+ )
79
+ return system, f"Goal: {input['goal']}" + (
80
+ f"\nContext:\n{input['context']}" if input.get("context") else ""
81
+ ) + "\nProduce the plan as JSON now."
82
+
83
+
84
+ def build_reflect_prompt(input: t.ReflectInput) -> tuple[str, str]:
85
+ system = "\n".join(
86
+ [
87
+ "You are a critical reviewer of an agent's progress. Decide whether the goal",
88
+ "has been achieved and give a one-line critique of what to do next.",
89
+ "",
90
+ 'Respond with ONLY a JSON object of the form {"done":true|false,"critique":"..."}.',
91
+ "No prose, no markdown fences.",
92
+ ]
93
+ )
94
+ return system, "\n".join(
95
+ [
96
+ f"Goal: {input['goal']}",
97
+ "",
98
+ "Steps so far:",
99
+ format_history(input["history"]),
100
+ "",
101
+ "Is the goal achieved? Respond with ONLY the JSON object.",
102
+ ]
103
+ )
104
+
105
+
106
+ def build_compress_prompt(input: t.CompressHistoryInput) -> tuple[str, str]:
107
+ maximum = js_string(nullish(input.get("maxWords"), 150))
108
+ system = "\n".join(
109
+ [
110
+ "You compress an AI agent's scratchpad into a short summary so it fits in a",
111
+ "limited context window. Preserve the key facts, decisions, and open threads;",
112
+ "drop redundancy and verbatim tool output.",
113
+ "",
114
+ f"Respond with ONLY the summary as plain prose, at most {maximum} words.",
115
+ "No preamble, no markdown fences, no bullet headers.",
116
+ ]
117
+ )
118
+ return system, "\n".join(
119
+ ["History to summarize:", format_history(input["history"]), "", "Write the summary now."]
120
+ )
121
+
122
+
123
+ def build_route_prompt(input: t.RouteInput) -> tuple[str, str]:
124
+ routes = "\n".join(f"- {r['name']}: {r['description']}" for r in input["routes"])
125
+ system = "\n".join(
126
+ [
127
+ "You are a router. Given a goal and a set of named routes, pick the single",
128
+ "best route to handle the goal.",
129
+ "",
130
+ "Available routes:",
131
+ routes,
132
+ "",
133
+ 'Respond with ONLY a JSON object of the form {"route":"<name>","reasoning":"..."}.',
134
+ "The route MUST be exactly one of the names listed above. No prose, "
135
+ "no markdown fences.",
136
+ ]
137
+ )
138
+ return system, "\n".join(
139
+ [f"Goal: {input['goal']}", "", "Which route? Respond with ONLY the JSON object."]
140
+ )
@@ -0,0 +1,37 @@
1
+ from render import TaskContext
2
+ from render_lab_tasks_llm.chat import resolve_model
3
+ from render_lab_tasks_llm.json import extract_json
4
+ from render_lab_tasks_llm.types import CompleteInput
5
+
6
+ from . import types as t
7
+ from ._app import app
8
+ from .client import CompletionDeps, completion_deps
9
+ from .prompts import build_reflect_prompt
10
+ from .retry import AGENT_RETRY
11
+
12
+
13
+ async def reflect_impl(
14
+ ctx: TaskContext, input: t.ReflectInput, *, deps: CompletionDeps | None = None
15
+ ) -> t.ReflectResult:
16
+ resolved = completion_deps(ctx, deps)
17
+ system, prompt = build_reflect_prompt(input)
18
+ request: CompleteInput = {
19
+ "prompt": prompt,
20
+ "system": system,
21
+ "model": resolve_model(input.get("model")),
22
+ }
23
+ if "ledger" in input:
24
+ request["ledger"] = input["ledger"]
25
+ response = await resolved.complete(request)
26
+ parsed = extract_json(response["text"])
27
+ parsed = parsed if isinstance(parsed, dict) else {}
28
+ return {
29
+ "done": parsed.get("done") is True,
30
+ "critique": parsed["critique"] if isinstance(parsed.get("critique"), str) else "",
31
+ "model": response["model"],
32
+ }
33
+
34
+
35
+ @app.task(name="agent.reflect", retry=AGENT_RETRY)
36
+ async def reflect(ctx: TaskContext, input: t.ReflectInput) -> t.ReflectResult:
37
+ return await reflect_impl(ctx, input)
@@ -0,0 +1,3 @@
1
+ from render import Retry
2
+
3
+ AGENT_RETRY = Retry(max_retries=2, wait_duration_ms=1000, backoff_scaling=2)
@@ -0,0 +1,48 @@
1
+ from render import TaskContext
2
+ from render_lab_tasks_llm.chat import resolve_model
3
+ from render_lab_tasks_llm.json import extract_json
4
+ from render_lab_tasks_llm.types import CompleteInput
5
+
6
+ from . import types as t
7
+ from ._app import app
8
+ from .client import CompletionDeps, completion_deps
9
+ from .prompts import build_route_prompt
10
+ from .retry import AGENT_RETRY
11
+
12
+
13
+ async def route_impl(
14
+ ctx: TaskContext, input: t.RouteInput, *, deps: CompletionDeps | None = None
15
+ ) -> t.RouteResult:
16
+ if not input["routes"]:
17
+ raise ValueError("agent.route: no routes provided to choose from.")
18
+ resolved = completion_deps(ctx, deps)
19
+ system, prompt = build_route_prompt(input)
20
+ request: CompleteInput = {
21
+ "prompt": prompt,
22
+ "system": system,
23
+ "model": resolve_model(input.get("model")),
24
+ }
25
+ if "ledger" in input:
26
+ request["ledger"] = input["ledger"]
27
+ response = await resolved.complete(request)
28
+ parsed = extract_json(response["text"])
29
+ parsed = parsed if isinstance(parsed, dict) else {}
30
+ chosen = parsed.get("route")
31
+ reasoning = parsed.get("reasoning")
32
+ reasoning = reasoning if isinstance(reasoning, str) else None
33
+ if isinstance(chosen, str) and any(r["name"] == chosen for r in input["routes"]):
34
+ result: t.RouteResult = {"route": chosen}
35
+ if reasoning is not None:
36
+ result["reasoning"] = reasoning
37
+ return result
38
+ return {
39
+ "route": input["routes"][0]["name"],
40
+ "reasoning": reasoning + " (fell back to first route)"
41
+ if reasoning
42
+ else "fell back to first route (model returned no valid route)",
43
+ }
44
+
45
+
46
+ @app.task(name="agent.route", retry=AGENT_RETRY)
47
+ async def route(ctx: TaskContext, input: t.RouteInput) -> t.RouteResult:
48
+ return await route_impl(ctx, input)
@@ -0,0 +1,34 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from render import TaskContext
4
+ from render_lab_tasks_core.http import nullish
5
+
6
+ from ._app import app
7
+ from .client import ToolBinding, tool_registry
8
+ from .retry import AGENT_RETRY
9
+ from .types import RunToolInput, RunToolResult
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class RunToolDeps:
14
+ tools: dict[str, ToolBinding] = field(default_factory=lambda: tool_registry)
15
+
16
+
17
+ async def run_tool_impl(
18
+ ctx: TaskContext, input: RunToolInput, *, deps: RunToolDeps | None = None
19
+ ) -> RunToolResult:
20
+ tools = (deps or RunToolDeps()).tools
21
+ if input["tool"] not in tools:
22
+ raise ValueError(
23
+ f'agent.runTool: unknown tool "{input["tool"]}". Register it with '
24
+ f"registerAgentTool(...) before calling it."
25
+ )
26
+ output = await tools[input["tool"]].run(ctx, nullish(input.get("args"), {}))
27
+ if not isinstance(output, str):
28
+ raise TypeError("Agent tools must return a string observation")
29
+ return {"tool": input["tool"], "output": output}
30
+
31
+
32
+ @app.task(name="agent.runTool", retry=AGENT_RETRY)
33
+ async def run_tool(ctx: TaskContext, input: RunToolInput) -> RunToolResult:
34
+ return await run_tool_impl(ctx, input)
@@ -0,0 +1,46 @@
1
+ import json
2
+ from typing import cast
3
+
4
+ from render import TaskContext
5
+ from render_lab_tasks_llm.chat import resolve_model
6
+ from render_lab_tasks_llm.json import extract_json
7
+ from render_lab_tasks_llm.types import CompleteInput
8
+
9
+ from . import types as t
10
+ from ._app import app
11
+ from .client import CompletionDeps, completion_deps
12
+ from .prompts import build_step_prompt
13
+ from .retry import AGENT_RETRY
14
+
15
+
16
+ async def step_impl(
17
+ ctx: TaskContext, input: t.StepInput, *, deps: CompletionDeps | None = None
18
+ ) -> t.StepResult:
19
+ resolved = completion_deps(ctx, deps)
20
+ system, prompt = build_step_prompt(input)
21
+ request: CompleteInput = {
22
+ "prompt": prompt,
23
+ "system": system,
24
+ "model": resolve_model(input.get("model")),
25
+ }
26
+ if "ledger" in input:
27
+ request["ledger"] = input["ledger"]
28
+ response = await resolved.complete(request)
29
+ action = extract_json(response["text"])
30
+ if not isinstance(action, dict) or action.get("type") not in ("tool", "final"):
31
+ raise ValueError(
32
+ f"agent.step: could not parse a valid AgentAction from model output: "
33
+ f"{response['text'][:200]}"
34
+ )
35
+ if action["type"] == "tool" and not action.get("tool"):
36
+ raise ValueError(
37
+ f"agent.step: model returned a tool action without a tool name: "
38
+ f"{response['text'][:200]}"
39
+ )
40
+ json.dumps(action, allow_nan=False)
41
+ return cast(t.StepResult, {**action, "model": response["model"]})
42
+
43
+
44
+ @app.task(name="agent.step", retry=AGENT_RETRY)
45
+ async def step(ctx: TaskContext, input: t.StepInput) -> t.StepResult:
46
+ return await step_impl(ctx, input)
@@ -0,0 +1,29 @@
1
+ from ._app import app
2
+ from .compress_history import compress_history, compress_history_impl
3
+ from .loop import loop, loop_impl
4
+ from .memory import memory, memory_impl
5
+ from .plan import plan, plan_impl
6
+ from .reflect import reflect, reflect_impl
7
+ from .route import route, route_impl
8
+ from .run_tool import run_tool, run_tool_impl
9
+ from .step import step, step_impl
10
+
11
+ __all__ = [
12
+ "app",
13
+ "compress_history",
14
+ "compress_history_impl",
15
+ "loop",
16
+ "loop_impl",
17
+ "memory",
18
+ "memory_impl",
19
+ "plan",
20
+ "plan_impl",
21
+ "reflect",
22
+ "reflect_impl",
23
+ "route",
24
+ "route_impl",
25
+ "run_tool",
26
+ "run_tool_impl",
27
+ "step",
28
+ "step_impl",
29
+ ]
@@ -0,0 +1,140 @@
1
+ """JSON contracts ported from the pinned TypeScript pack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, NotRequired, TypedDict
6
+
7
+
8
+ class MemorySetResult(TypedDict):
9
+ key: str
10
+ ok: Literal[True]
11
+
12
+
13
+ class MemoryGetResult(TypedDict):
14
+ key: str
15
+ value: str | None
16
+
17
+
18
+ type Json = str | float | bool | None | list[Json] | dict[str, Json]
19
+
20
+
21
+ class AgentTool(TypedDict):
22
+ name: str
23
+ description: str
24
+ parameters: dict[str, Json]
25
+
26
+
27
+ class AgentStepRecord(TypedDict):
28
+ thought: NotRequired[str]
29
+ tool: NotRequired[str]
30
+ args: NotRequired[dict[str, Json]]
31
+ observation: NotRequired[str]
32
+
33
+
34
+ class AgentAction(TypedDict):
35
+ type: Literal["tool"] | Literal["final"]
36
+ thought: NotRequired[str]
37
+ tool: NotRequired[str]
38
+ args: NotRequired[dict[str, Json]]
39
+ answer: NotRequired[str]
40
+
41
+
42
+ class PlanInput(TypedDict):
43
+ goal: str
44
+ context: NotRequired[str]
45
+ model: NotRequired[str]
46
+ ledger: NotRequired[str]
47
+
48
+
49
+ class PlanResult(TypedDict):
50
+ steps: list[str]
51
+ model: str
52
+
53
+
54
+ class StepInput(TypedDict):
55
+ goal: str
56
+ history: list[AgentStepRecord]
57
+ tools: list[AgentTool]
58
+ system: NotRequired[str]
59
+ model: NotRequired[str]
60
+ ledger: NotRequired[str]
61
+
62
+
63
+ class StepResult(AgentAction):
64
+ model: str
65
+
66
+
67
+ class ReflectInput(TypedDict):
68
+ goal: str
69
+ history: list[AgentStepRecord]
70
+ model: NotRequired[str]
71
+ ledger: NotRequired[str]
72
+
73
+
74
+ class ReflectResult(TypedDict):
75
+ done: bool
76
+ critique: str
77
+ model: str
78
+
79
+
80
+ class LoopInput(TypedDict):
81
+ goal: str
82
+ toolNames: NotRequired[list[str]]
83
+ maxSteps: NotRequired[float]
84
+ system: NotRequired[str]
85
+ model: NotRequired[str]
86
+ ledger: NotRequired[str]
87
+
88
+
89
+ class LoopResult(TypedDict):
90
+ answer: str
91
+ steps: list[AgentStepRecord]
92
+ stepCount: float
93
+ stoppedReason: Literal["final"] | Literal["maxSteps"]
94
+
95
+
96
+ class RunToolInput(TypedDict):
97
+ tool: str
98
+ args: NotRequired[dict[str, Json]]
99
+
100
+
101
+ class RunToolResult(TypedDict):
102
+ tool: str
103
+ output: str
104
+
105
+
106
+ class CompressHistoryInput(TypedDict):
107
+ history: list[AgentStepRecord]
108
+ maxWords: NotRequired[float]
109
+ model: NotRequired[str]
110
+ ledger: NotRequired[str]
111
+
112
+
113
+ class CompressHistoryResult(TypedDict):
114
+ summary: str
115
+
116
+
117
+ class AgentRoute(TypedDict):
118
+ name: str
119
+ description: str
120
+
121
+
122
+ class RouteInput(TypedDict):
123
+ goal: str
124
+ routes: list[AgentRoute]
125
+ model: NotRequired[str]
126
+ ledger: NotRequired[str]
127
+
128
+
129
+ class RouteResult(TypedDict):
130
+ route: str
131
+ reasoning: NotRequired[str]
132
+
133
+
134
+ class MemoryInput(TypedDict):
135
+ op: Literal["get"] | Literal["set"]
136
+ key: str
137
+ value: NotRequired[str]
138
+
139
+
140
+ type MemoryResult = MemoryGetResult | MemorySetResult