treelace 0.1.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 (48) hide show
  1. treelace/__init__.py +21 -0
  2. treelace/app.py +372 -0
  3. treelace/cli.py +76 -0
  4. treelace/coding_tool.py +24 -0
  5. treelace/compiler/__init__.py +16 -0
  6. treelace/compiler/compile.py +198 -0
  7. treelace/compiler/compiled.py +43 -0
  8. treelace/compiler/diagnostics.py +23 -0
  9. treelace/compiler/inspect.py +133 -0
  10. treelace/errors.py +46 -0
  11. treelace/ir/__init__.py +39 -0
  12. treelace/ir/eval.py +99 -0
  13. treelace/ir/expressions.py +55 -0
  14. treelace/ir/nodes.py +115 -0
  15. treelace/json_types.py +10 -0
  16. treelace/models/__init__.py +36 -0
  17. treelace/models/fake.py +37 -0
  18. treelace/models/openai_adapter.py +83 -0
  19. treelace/models/protocol.py +52 -0
  20. treelace/planner/__init__.py +26 -0
  21. treelace/planner/inspect.py +64 -0
  22. treelace/planner/models.py +128 -0
  23. treelace/planner/orchestrator.py +336 -0
  24. treelace/planner/prompts.py +77 -0
  25. treelace/py.typed +1 -0
  26. treelace/runner.py +231 -0
  27. treelace/runtime/__init__.py +7 -0
  28. treelace/runtime/engine.py +528 -0
  29. treelace/runtime/events.py +79 -0
  30. treelace/runtime/result.py +34 -0
  31. treelace/sandbox.py +562 -0
  32. treelace/sessions/__init__.py +22 -0
  33. treelace/sessions/artifacts.py +54 -0
  34. treelace/sessions/memory.py +47 -0
  35. treelace/sessions/models.py +92 -0
  36. treelace/sessions/protocols.py +32 -0
  37. treelace/tools/__init__.py +20 -0
  38. treelace/tools/context.py +34 -0
  39. treelace/tools/definition.py +53 -0
  40. treelace/tools/inference.py +167 -0
  41. treelace/tools/registry.py +88 -0
  42. treelace/tools/result.py +33 -0
  43. treelace/tools/spec.py +14 -0
  44. treelace-0.1.0.dist-info/METADATA +243 -0
  45. treelace-0.1.0.dist-info/RECORD +48 -0
  46. treelace-0.1.0.dist-info/WHEEL +4 -0
  47. treelace-0.1.0.dist-info/entry_points.txt +2 -0
  48. treelace-0.1.0.dist-info/licenses/LICENSE +177 -0
treelace/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Treelace public API for simple LLM applications."""
2
+
3
+ from treelace.app import NeedsInput, Run, Treelace
4
+ from treelace.planner.inspect import execution_plan_text
5
+ from treelace.planner.prompts import ConversationTurn
6
+ from treelace.tools.context import ToolContext
7
+ from treelace.tools.inference import tool
8
+ from treelace.tools.result import ClarificationRequest, ToolOutput, ToolResult
9
+
10
+ __all__ = [
11
+ "ClarificationRequest",
12
+ "ConversationTurn",
13
+ "NeedsInput",
14
+ "Run",
15
+ "ToolContext",
16
+ "ToolOutput",
17
+ "ToolResult",
18
+ "Treelace",
19
+ "execution_plan_text",
20
+ "tool",
21
+ ]
treelace/app.py ADDED
@@ -0,0 +1,372 @@
1
+ """High-level Treelace application facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ from typing import Any, Coroutine, Iterable, Literal, TypeVar
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
11
+
12
+ from treelace.json_types import JsonValue
13
+ from treelace.models import OpenAIChatModel
14
+ from treelace.models.protocol import Message, Model, ModelContext, ModelRequest, StructuredOutput
15
+ from treelace.planner.inspect import execution_plan_text
16
+ from treelace.planner.models import ChatPlan, PlanDecision
17
+ from treelace.planner.prompts import ConversationTurn, MasterPromptManager
18
+ from treelace.runner import Runner
19
+ from treelace.runtime.engine import Engine
20
+ from treelace.runtime.events import Event
21
+ from treelace.runtime.result import RunResult
22
+ from treelace.tools.definition import ToolDefinition
23
+ from treelace.tools.registry import ToolRegistry
24
+
25
+
26
+ class NeedsInput(BaseModel):
27
+ """Public waiting-state payload used by applications."""
28
+
29
+ model_config = ConfigDict(extra="forbid", frozen=True)
30
+ prompt: str
31
+ options: list[str] = Field(default_factory=list)
32
+ missing_fields: list[str] = Field(default_factory=list)
33
+
34
+
35
+ class Run(BaseModel):
36
+ """Public result envelope returned by Treelace runs."""
37
+
38
+ model_config = ConfigDict(extra="forbid", frozen=True)
39
+ status: Literal["succeeded", "failed", "waiting"]
40
+ question: str
41
+ answer: str | None = None
42
+ data: JsonValue | None = None
43
+ error: str | None = None
44
+ needs_input: NeedsInput | None = None
45
+ plan: PlanDecision | None = None
46
+ plan_text: str | None = None
47
+ events: list[Event] = Field(default_factory=list)
48
+ session_id: str = Field(repr=False)
49
+ clarification_token: str | None = Field(default=None, repr=False)
50
+ raw_output: JsonValue | None = Field(default=None, repr=False)
51
+
52
+ @property
53
+ def prompt(self) -> str | None:
54
+ return self.needs_input.prompt if self.needs_input is not None else None
55
+
56
+ @model_validator(mode="after")
57
+ def _validate_state(self) -> "Run":
58
+ if self.status == "waiting":
59
+ if self.needs_input is None:
60
+ raise ValueError("waiting run requires needs_input")
61
+ if not self.clarification_token:
62
+ raise ValueError("waiting run requires clarification token")
63
+ if self.status == "failed" and not self.error:
64
+ raise ValueError("failed run requires error")
65
+ if self.status == "succeeded" and self.error is not None:
66
+ raise ValueError("succeeded run cannot include error")
67
+ return self
68
+
69
+
70
+ def _resolve_openai_model(spec: str) -> Model:
71
+ provider, separator, model_name = spec.partition(":")
72
+ if not separator:
73
+ provider = "openai"
74
+ model_name = spec
75
+ provider = provider.strip().lower()
76
+ if provider not in {"openai", "azure-openai", "azure"}:
77
+ raise ValueError(f"unsupported model provider: {provider}")
78
+
79
+ model = model_name.strip() or (
80
+ os.getenv("OPENAI_MODEL")
81
+ or os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT")
82
+ or os.getenv("AZURE_OPENAI_MODEL_NAME")
83
+ )
84
+ if not model:
85
+ raise ValueError("missing model name (for example: 'openai:gpt-5-mini')")
86
+
87
+ api_key = os.getenv("OPENAI_API_KEY") or os.getenv("AZURE_OPENAI_API_KEY")
88
+ if not api_key:
89
+ raise ValueError("missing OPENAI_API_KEY or AZURE_OPENAI_API_KEY")
90
+
91
+ base_url = os.getenv("OPENAI_BASE_URL")
92
+ endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
93
+ if not base_url and endpoint:
94
+ base_url = endpoint.rstrip("/") + "/openai/v1/"
95
+
96
+ return OpenAIChatModel(model=model, api_key=api_key, base_url=base_url)
97
+
98
+
99
+ def _resolve_model(model: Model | str) -> Model:
100
+ if isinstance(model, str):
101
+ return _resolve_openai_model(model)
102
+ return model
103
+
104
+
105
+ def _extract_data(output: JsonValue | None) -> JsonValue | None:
106
+ if not isinstance(output, dict):
107
+ return output
108
+ model_context = output.get("model_context")
109
+ if model_context is not None:
110
+ return model_context
111
+ trimmed = {
112
+ key: value
113
+ for key, value in output.items()
114
+ if key not in {"artifact_refs", "clarification_request"}
115
+ }
116
+ return trimmed or None
117
+
118
+
119
+ def _extract_artifact_refs(output: JsonValue | None) -> list[str]:
120
+ if not isinstance(output, dict):
121
+ return []
122
+ refs = output.get("artifact_refs")
123
+ if not isinstance(refs, list):
124
+ return []
125
+ return [item for item in refs if isinstance(item, str)]
126
+
127
+
128
+ T = TypeVar("T")
129
+
130
+
131
+ class Treelace:
132
+ """Simple facade for running small LLM-driven tool applications."""
133
+
134
+ def __init__(
135
+ self,
136
+ *,
137
+ model: Model | str | None = None,
138
+ tools: ToolRegistry | Iterable[Any] | None = None,
139
+ instructions: str | None = None,
140
+ planner_model: Model | str | None = None,
141
+ responder_model: Model | str | None = None,
142
+ prompt_manager: MasterPromptManager | None = None,
143
+ engine: Engine | None = None,
144
+ ) -> None:
145
+ planner_source = planner_model if planner_model is not None else model
146
+ if planner_source is None:
147
+ raise ValueError("model or planner_model is required")
148
+ responder_source = responder_model if responder_model is not None else planner_source
149
+
150
+ if prompt_manager is not None and instructions:
151
+ raise ValueError("provide either prompt_manager or instructions, not both")
152
+
153
+ manager = prompt_manager or MasterPromptManager(master_prompt=instructions)
154
+ self._instructions = instructions.strip() if isinstance(instructions, str) else ""
155
+ self._runner = Runner(
156
+ model=_resolve_model(planner_source),
157
+ tools=tools,
158
+ prompt_manager=manager,
159
+ engine=engine,
160
+ )
161
+ self._responder_model = _resolve_model(responder_source)
162
+
163
+ @property
164
+ def tools(self) -> ToolRegistry:
165
+ return self._runner.tools
166
+
167
+ @property
168
+ def model(self) -> Model:
169
+ """Model used for planning and for tool-side completions."""
170
+
171
+ return self._runner.planner.model
172
+
173
+ def register_tool(self, tool_like: Any) -> ToolDefinition:
174
+ return self._runner.register_tool(tool_like)
175
+
176
+ async def aplan(
177
+ self,
178
+ *,
179
+ question: str,
180
+ history: list[ConversationTurn] | None = None,
181
+ session_id: str | None = None,
182
+ workflow_id: str = "planned_workflow",
183
+ ) -> PlanDecision:
184
+ plan, _ = await self._runner.plan(
185
+ question=question,
186
+ history=history,
187
+ session_id=session_id,
188
+ workflow_id=workflow_id,
189
+ )
190
+ return plan
191
+
192
+ def plan(
193
+ self,
194
+ *,
195
+ question: str,
196
+ history: list[ConversationTurn] | None = None,
197
+ session_id: str | None = None,
198
+ workflow_id: str = "planned_workflow",
199
+ ) -> PlanDecision:
200
+ return _run_sync(
201
+ self.aplan(
202
+ question=question,
203
+ history=history,
204
+ session_id=session_id,
205
+ workflow_id=workflow_id,
206
+ )
207
+ )
208
+
209
+ async def arun(
210
+ self,
211
+ question: str,
212
+ *,
213
+ history: list[ConversationTurn] | None = None,
214
+ inputs: JsonValue | None = None,
215
+ respond: bool = True,
216
+ session_id: str | None = None,
217
+ workflow_id: str = "planned_workflow",
218
+ ) -> Run:
219
+ result = await self._runner.run(
220
+ question=question,
221
+ inputs={} if inputs is None else inputs,
222
+ history=history,
223
+ session_id=session_id,
224
+ workflow_id=workflow_id,
225
+ )
226
+ return await self._to_public_run(question=question, result=result, respond=respond)
227
+
228
+ def run(
229
+ self,
230
+ question: str,
231
+ *,
232
+ history: list[ConversationTurn] | None = None,
233
+ inputs: JsonValue | None = None,
234
+ respond: bool = True,
235
+ session_id: str | None = None,
236
+ workflow_id: str = "planned_workflow",
237
+ ) -> Run:
238
+ return _run_sync(
239
+ self.arun(
240
+ question,
241
+ history=history,
242
+ inputs=inputs,
243
+ respond=respond,
244
+ session_id=session_id,
245
+ workflow_id=workflow_id,
246
+ )
247
+ )
248
+
249
+ run_sync = run
250
+
251
+ async def aresume(self, run: Run, answer: str, *, respond: bool = True) -> Run:
252
+ if run.status != "waiting":
253
+ raise ValueError("resume requires a waiting run")
254
+ if run.clarification_token is None:
255
+ raise ValueError("run does not have a clarification token")
256
+ resumed = await self._runner.resume(
257
+ session_id=run.session_id,
258
+ token=run.clarification_token,
259
+ decision=answer,
260
+ )
261
+ return await self._to_public_run(question=run.question, result=resumed, respond=respond)
262
+
263
+ def resume(self, run: Run, answer: str, *, respond: bool = True) -> Run:
264
+ return _run_sync(self.aresume(run, answer, respond=respond))
265
+
266
+ resume_sync = resume
267
+
268
+ async def _to_public_run(
269
+ self,
270
+ *,
271
+ question: str,
272
+ result: RunResult,
273
+ respond: bool,
274
+ ) -> Run:
275
+ plan_text = execution_plan_text(result.plan) if result.plan is not None else None
276
+ pending = result.pending_clarification
277
+ needs_input = (
278
+ NeedsInput(
279
+ prompt=pending.prompt,
280
+ options=list(pending.options),
281
+ missing_fields=list(pending.missing_fields),
282
+ )
283
+ if pending is not None
284
+ else None
285
+ )
286
+
287
+ data = _extract_data(result.output)
288
+ answer: str | None = None
289
+ if isinstance(result.plan, ChatPlan):
290
+ answer = result.plan.response
291
+ data = {"message": result.plan.response}
292
+ elif result.status == "succeeded" and respond:
293
+ answer = await self._synthesize_answer(
294
+ question=question,
295
+ plan_text=plan_text or "(no plan)",
296
+ data=data,
297
+ artifact_refs=_extract_artifact_refs(result.output),
298
+ session_id=result.session_id,
299
+ )
300
+
301
+ return Run(
302
+ status=result.status,
303
+ question=question,
304
+ answer=answer,
305
+ data=data,
306
+ error=result.error,
307
+ needs_input=needs_input,
308
+ plan=result.plan,
309
+ plan_text=plan_text,
310
+ events=list(result.events),
311
+ session_id=result.session_id,
312
+ clarification_token=pending.token if pending is not None else None,
313
+ raw_output=result.output,
314
+ )
315
+
316
+ async def _synthesize_answer(
317
+ self,
318
+ *,
319
+ question: str,
320
+ plan_text: str,
321
+ data: JsonValue | None,
322
+ artifact_refs: list[str],
323
+ session_id: str,
324
+ ) -> str:
325
+ if data is None:
326
+ return "No result data was produced."
327
+
328
+ system_lines = [
329
+ "You are Treelace's response synthesizer.",
330
+ "Answer strictly from the provided execution data.",
331
+ "Do not invent tools, values, or files.",
332
+ "If data is missing for part of the question, say so clearly.",
333
+ "Keep the answer concise unless the question asks for detail.",
334
+ ]
335
+ if self._instructions:
336
+ system_lines.append(f"Application instructions:\n{self._instructions}")
337
+
338
+ prompt_payload = {
339
+ "question": question,
340
+ "plan": plan_text,
341
+ "data": data,
342
+ "artifact_refs": artifact_refs,
343
+ }
344
+ response = await self._responder_model.complete(
345
+ ModelRequest(
346
+ messages=[
347
+ Message(role="system", content="\n".join(system_lines)),
348
+ Message(
349
+ role="user",
350
+ content=json.dumps(prompt_payload, indent=2, ensure_ascii=True, sort_keys=True),
351
+ ),
352
+ ]
353
+ ),
354
+ context=ModelContext(session_id=session_id, metadata={"stage": "response_synthesis"}),
355
+ )
356
+ if isinstance(response.output, StructuredOutput):
357
+ answer = response.output.data.get("answer")
358
+ if isinstance(answer, str) and answer.strip():
359
+ return answer.strip()
360
+ return json.dumps(response.output.data, ensure_ascii=True, sort_keys=True)
361
+ text = response.output.text.strip()
362
+ if text:
363
+ return text
364
+ return json.dumps(data, ensure_ascii=True, sort_keys=True)
365
+
366
+
367
+ def _run_sync(coro: Coroutine[Any, Any, T]) -> T:
368
+ try:
369
+ asyncio.get_running_loop()
370
+ except RuntimeError:
371
+ return asyncio.run(coro)
372
+ raise RuntimeError("synchronous Treelace methods cannot run inside an active event loop")
treelace/cli.py ADDED
@@ -0,0 +1,76 @@
1
+ """Treelace CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from treelace.compiler.compile import compile_workflow
10
+ from treelace.compiler.inspect import dependency_mermaid, dependency_text
11
+ from treelace.ir.nodes import Workflow
12
+
13
+
14
+ def _load_workflow(path: str) -> Workflow:
15
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
16
+ return Workflow.model_validate(payload)
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ parser = argparse.ArgumentParser(prog="treelace")
21
+ subparsers = parser.add_subparsers(dest="command", required=True)
22
+
23
+ validate_cmd = subparsers.add_parser("validate")
24
+ validate_cmd.add_argument("workflow")
25
+
26
+ inspect_cmd = subparsers.add_parser("inspect")
27
+ inspect_cmd.add_argument("workflow")
28
+
29
+ render_cmd = subparsers.add_parser("render")
30
+ render_cmd.add_argument("workflow")
31
+ render_cmd.add_argument("-o", "--output", required=True)
32
+
33
+ schema_cmd = subparsers.add_parser("schema")
34
+ schema_cmd.add_argument(
35
+ "--target",
36
+ choices=["workflow", "plan_decision"],
37
+ default="workflow",
38
+ )
39
+
40
+ args = parser.parse_args(argv)
41
+
42
+ if args.command == "validate":
43
+ workflow = _load_workflow(args.workflow)
44
+ compile_workflow(workflow)
45
+ print("ok")
46
+ return 0
47
+
48
+ if args.command == "inspect":
49
+ workflow = _load_workflow(args.workflow)
50
+ compiled = compile_workflow(workflow)
51
+ print(dependency_text(compiled))
52
+ return 0
53
+
54
+ if args.command == "render":
55
+ workflow = _load_workflow(args.workflow)
56
+ compiled = compile_workflow(workflow)
57
+ mermaid = dependency_mermaid(compiled)
58
+ Path(args.output).write_text(mermaid, encoding="utf-8")
59
+ print(args.output)
60
+ return 0
61
+
62
+ if args.command == "schema":
63
+ if args.target == "workflow":
64
+ schema = Workflow.model_json_schema()
65
+ else:
66
+ from treelace.planner.models import PLAN_DECISION_ADAPTER
67
+
68
+ schema = PLAN_DECISION_ADAPTER.json_schema()
69
+ print(json.dumps(schema, indent=2))
70
+ return 0
71
+
72
+ return 1
73
+
74
+
75
+ if __name__ == "__main__":
76
+ raise SystemExit(main())
@@ -0,0 +1,24 @@
1
+ """Small local coding utility for dataframe-like payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def coding_tool(
9
+ dataframes: list[Any],
10
+ dataframe_schemas: list[dict[str, Any]],
11
+ system_message: str = "",
12
+ ) -> tuple[dict[str, Any], list[Any]]:
13
+ """Return compact metadata and pass-through artifacts.
14
+
15
+ This intentionally avoids model calls and is safe for local use as a helper
16
+ inside registered tools.
17
+ """
18
+
19
+ _ = dataframe_schemas
20
+ summary = {
21
+ "dataframe_count": len(dataframes),
22
+ "system_message_preview": system_message[:200],
23
+ }
24
+ return summary, dataframes
@@ -0,0 +1,16 @@
1
+ """Compiler exports."""
2
+
3
+ from treelace.compiler.compile import compile_workflow
4
+ from treelace.compiler.compiled import CompiledOperation, CompiledWorkflow
5
+ from treelace.compiler.diagnostics import Diagnostic
6
+ from treelace.compiler.inspect import dependency_json, dependency_mermaid, dependency_text
7
+
8
+ __all__ = [
9
+ "CompiledOperation",
10
+ "CompiledWorkflow",
11
+ "Diagnostic",
12
+ "compile_workflow",
13
+ "dependency_json",
14
+ "dependency_mermaid",
15
+ "dependency_text",
16
+ ]