tilo 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.
- tilo/__init__.py +1 -0
- tilo/adapters/__init__.py +5 -0
- tilo/adapters/a2a.py +29 -0
- tilo/adapters/acp.py +29 -0
- tilo/adapters/langchain.py +347 -0
- tilo/adapters/mcp.py +125 -0
- tilo/api/__init__.py +1 -0
- tilo/api/deps.py +20 -0
- tilo/api/routes/__init__.py +22 -0
- tilo/api/routes/agents.py +40 -0
- tilo/api/routes/apps.py +55 -0
- tilo/api/routes/artifacts.py +83 -0
- tilo/api/routes/channels.py +25 -0
- tilo/api/routes/confirmations.py +57 -0
- tilo/api/routes/conversations.py +142 -0
- tilo/api/routes/demo.py +19 -0
- tilo/api/routes/feedback.py +34 -0
- tilo/api/routes/interactions.py +58 -0
- tilo/api/routes/memories.py +146 -0
- tilo/api/routes/messages.py +52 -0
- tilo/api/routes/projects.py +40 -0
- tilo/api/routes/runs.py +42 -0
- tilo/api/routes/skills.py +104 -0
- tilo/api/routes/system.py +44 -0
- tilo/api/routes/tasks.py +54 -0
- tilo/api/routes/tools.py +67 -0
- tilo/api/routes/workspaces.py +40 -0
- tilo/cli.py +107 -0
- tilo/core/__init__.py +1 -0
- tilo/core/config.py +46 -0
- tilo/core/database.py +24 -0
- tilo/core/migrations.py +67 -0
- tilo/core/time.py +5 -0
- tilo/main.py +93 -0
- tilo/models/__init__.py +53 -0
- tilo/models/domain.py +404 -0
- tilo/schemas/__init__.py +3 -0
- tilo/schemas/artifact.py +241 -0
- tilo/schemas/domain.py +533 -0
- tilo/schemas/surface.py +504 -0
- tilo/services/__init__.py +1 -0
- tilo/services/agent_context/__init__.py +3 -0
- tilo/services/agent_context/builder.py +127 -0
- tilo/services/agent_runtime/__init__.py +3 -0
- tilo/services/agent_runtime/executor.py +38 -0
- tilo/services/agent_runtime/message_flow.py +116 -0
- tilo/services/agent_runtime/planner.py +99 -0
- tilo/services/agent_runtime/prompt_builder.py +72 -0
- tilo/services/agent_runtime/run_manager.py +457 -0
- tilo/services/agent_runtime/state_machine.py +87 -0
- tilo/services/apps/__init__.py +4 -0
- tilo/services/apps/loader.py +88 -0
- tilo/services/apps/schemas.py +48 -0
- tilo/services/artifact/__init__.py +3 -0
- tilo/services/artifact/actions.py +523 -0
- tilo/services/artifact/aip_generator.py +887 -0
- tilo/services/artifact/contract_llm.py +114 -0
- tilo/services/artifact/generator.py +296 -0
- tilo/services/artifact/persistence.py +44 -0
- tilo/services/artifact/spec.py +923 -0
- tilo/services/bootstrap.py +50 -0
- tilo/services/channels/__init__.py +2 -0
- tilo/services/channels/telegram/__init__.py +4 -0
- tilo/services/channels/telegram/adapter.py +81 -0
- tilo/services/channels/telegram/renderer.py +59 -0
- tilo/services/channels/telegram/types.py +18 -0
- tilo/services/channels/telegram/webhook.py +259 -0
- tilo/services/channels/types.py +64 -0
- tilo/services/context_reflection/__init__.py +11 -0
- tilo/services/context_reflection/schemas.py +31 -0
- tilo/services/context_reflection/service.py +224 -0
- tilo/services/conversations/__init__.py +3 -0
- tilo/services/conversations/constants.py +25 -0
- tilo/services/conversations/messages.py +121 -0
- tilo/services/conversations/service.py +190 -0
- tilo/services/demo/__init__.py +6 -0
- tilo/services/demo/contracts.py +41 -0
- tilo/services/improvement/__init__.py +4 -0
- tilo/services/improvement/candidates.py +144 -0
- tilo/services/improvement/metrics.py +50 -0
- tilo/services/inbox/__init__.py +3 -0
- tilo/services/inbox/confirmations.py +77 -0
- tilo/services/interaction_policy/__init__.py +3 -0
- tilo/services/interaction_policy/schemas.py +125 -0
- tilo/services/interaction_policy/service.py +161 -0
- tilo/services/interactions/__init__.py +1 -0
- tilo/services/interactions/events.py +51 -0
- tilo/services/memory/__init__.py +4 -0
- tilo/services/memory/behaviour.py +320 -0
- tilo/services/memory/extraction.py +124 -0
- tilo/services/memory/recall.py +209 -0
- tilo/services/memory/writer.py +160 -0
- tilo/services/models/__init__.py +11 -0
- tilo/services/models/client.py +416 -0
- tilo/services/models/errors.py +18 -0
- tilo/services/models/prompts.py +105 -0
- tilo/services/models/schemas.py +129 -0
- tilo/services/skill/__init__.py +3 -0
- tilo/services/skill/selector.py +27 -0
- tilo/services/surface/__init__.py +23 -0
- tilo/services/surface/composer.py +519 -0
- tilo/services/surface/persistence.py +145 -0
- tilo/services/surfaces/__init__.py +3 -0
- tilo/services/surfaces/constants.py +13 -0
- tilo/services/surfaces/rich_links.py +31 -0
- tilo/services/tools/__init__.py +3 -0
- tilo/services/tools/invocation.py +129 -0
- tilo/services/tools/registry.py +40 -0
- tilo/services/trace/__init__.py +3 -0
- tilo/services/trace/recorder.py +160 -0
- tilo-0.1.0.dist-info/METADATA +24 -0
- tilo-0.1.0.dist-info/RECORD +114 -0
- tilo-0.1.0.dist-info/WHEEL +4 -0
- tilo-0.1.0.dist-info/entry_points.txt +2 -0
tilo/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
tilo/adapters/a2a.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""A2A (Agent-to-Agent) → Tilo AIP adapter (stub).
|
|
2
|
+
|
|
3
|
+
Converts Google A2A protocol task results into Tilo AIP specs.
|
|
4
|
+
|
|
5
|
+
Status: Interface only. Implementation planned for future milestone.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def a2a_task_to_spec(task_result: dict[str, Any]) -> dict[str, Any]:
|
|
12
|
+
"""Convert an A2A task result to a Tilo AIP spec. Stub."""
|
|
13
|
+
return {
|
|
14
|
+
"version": "tilo/aip/v1",
|
|
15
|
+
"title": task_result.get("name", "A2A Task Result"),
|
|
16
|
+
"status": "ready",
|
|
17
|
+
"blocks": [
|
|
18
|
+
{
|
|
19
|
+
"id": "a2a_result",
|
|
20
|
+
"type": "markdown",
|
|
21
|
+
"props": {"content": str(task_result.get("output", "No output."))},
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
"views": [],
|
|
25
|
+
"actions": [],
|
|
26
|
+
"provenance": [],
|
|
27
|
+
"memory_refs": [],
|
|
28
|
+
"follow_ups": [],
|
|
29
|
+
}
|
tilo/adapters/acp.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""ACP (Agent Communication Protocol) → Tilo AIP adapter (stub).
|
|
2
|
+
|
|
3
|
+
Converts ACP messages into Tilo AIP specs.
|
|
4
|
+
|
|
5
|
+
Status: Interface only. Implementation planned for future milestone.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def acp_message_to_spec(message: dict[str, Any]) -> dict[str, Any]:
|
|
12
|
+
"""Convert an ACP message to a Tilo AIP spec. Stub."""
|
|
13
|
+
return {
|
|
14
|
+
"version": "tilo/aip/v1",
|
|
15
|
+
"title": message.get("subject", "ACP Message"),
|
|
16
|
+
"status": "ready",
|
|
17
|
+
"blocks": [
|
|
18
|
+
{
|
|
19
|
+
"id": "acp_content",
|
|
20
|
+
"type": "markdown",
|
|
21
|
+
"props": {"content": str(message.get("body", "No content."))},
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
"views": [],
|
|
25
|
+
"actions": [],
|
|
26
|
+
"provenance": [],
|
|
27
|
+
"memory_refs": [],
|
|
28
|
+
"follow_ups": [],
|
|
29
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""LangChain → Tilo AIP adapter.
|
|
2
|
+
|
|
3
|
+
Provides a callback handler that captures LangChain chain output
|
|
4
|
+
and converts it into a Tilo AIP spec.
|
|
5
|
+
|
|
6
|
+
Usage (callback — integrates with any LangChain chain):
|
|
7
|
+
from tilo.adapters.langchain import TiloCallbackHandler
|
|
8
|
+
|
|
9
|
+
handler = TiloCallbackHandler(run_id="my-tilo-run")
|
|
10
|
+
chain.invoke(input, config={"callbacks": [handler]})
|
|
11
|
+
spec = handler.to_spec() # → Tilo AIP v1 dict
|
|
12
|
+
|
|
13
|
+
Usage (direct conversion — convert a chain output dict):
|
|
14
|
+
from tilo.adapters.langchain import langchain_result_to_spec
|
|
15
|
+
|
|
16
|
+
spec = langchain_result_to_spec("MyChain", outputs)
|
|
17
|
+
|
|
18
|
+
Design notes:
|
|
19
|
+
- No hard dependency on langchain at import time (duck-typed interface).
|
|
20
|
+
The handler works with any object that calls these methods.
|
|
21
|
+
- Structured output (dict / list-of-dicts) maps to metric / table blocks.
|
|
22
|
+
- Tool calls map to tool_preview blocks with success / error status.
|
|
23
|
+
- on_chain_end is a fallback: if finer-grained callbacks already captured
|
|
24
|
+
output, it is skipped to avoid duplication.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import uuid
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------- #
|
|
35
|
+
# Internal helpers #
|
|
36
|
+
# --------------------------------------------------------------------------- #
|
|
37
|
+
|
|
38
|
+
def _new_id(prefix: str = "lc") -> str:
|
|
39
|
+
return f"{prefix}_{uuid.uuid4().hex[:8]}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _text_to_block(text: str, block_id: str | None = None) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"id": block_id or _new_id("lc_text"),
|
|
45
|
+
"type": "markdown",
|
|
46
|
+
"title": None,
|
|
47
|
+
"props": {"content": text.strip()},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _tool_result_to_block(
|
|
52
|
+
tool_name: str,
|
|
53
|
+
output: str,
|
|
54
|
+
block_id: str | None = None,
|
|
55
|
+
*,
|
|
56
|
+
is_error: bool = False,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"id": block_id or _new_id("lc_tool"),
|
|
60
|
+
"type": "tool_preview",
|
|
61
|
+
"title": tool_name,
|
|
62
|
+
"props": {
|
|
63
|
+
"tool_name": tool_name,
|
|
64
|
+
"status": "error" if is_error else "success",
|
|
65
|
+
"output": str(output)[:2000],
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _structured_output_to_blocks(
|
|
71
|
+
data: Any,
|
|
72
|
+
block_id_prefix: str = "lc_struct",
|
|
73
|
+
) -> list[dict[str, Any]]:
|
|
74
|
+
"""Convert a structured value (dict / list) to appropriate block types.
|
|
75
|
+
|
|
76
|
+
- Flat dict with ≤8 numeric/short-string values → metric blocks.
|
|
77
|
+
- List of dicts → table block.
|
|
78
|
+
- Anything else → markdown with JSON pretty-print.
|
|
79
|
+
"""
|
|
80
|
+
if isinstance(data, dict):
|
|
81
|
+
is_metrics = (
|
|
82
|
+
len(data) <= 8
|
|
83
|
+
and all(
|
|
84
|
+
isinstance(v, (int, float))
|
|
85
|
+
or (isinstance(v, str) and len(v) < 40)
|
|
86
|
+
for v in data.values()
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
if is_metrics:
|
|
90
|
+
return [
|
|
91
|
+
{
|
|
92
|
+
"id": f"{block_id_prefix}_{i}",
|
|
93
|
+
"type": "metric",
|
|
94
|
+
"title": key.replace("_", " ").title(),
|
|
95
|
+
"props": {
|
|
96
|
+
"label": key.replace("_", " ").title(),
|
|
97
|
+
"value": str(value),
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
for i, (key, value) in enumerate(data.items())
|
|
101
|
+
]
|
|
102
|
+
return [_text_to_block(
|
|
103
|
+
f"```json\n{json.dumps(data, indent=2, ensure_ascii=False)}\n```",
|
|
104
|
+
f"{block_id_prefix}_json",
|
|
105
|
+
)]
|
|
106
|
+
|
|
107
|
+
if isinstance(data, list) and data and isinstance(data[0], dict):
|
|
108
|
+
columns = list(data[0].keys())
|
|
109
|
+
rows = [[str(row.get(col, "")) for col in columns] for row in data]
|
|
110
|
+
return [{
|
|
111
|
+
"id": f"{block_id_prefix}_table",
|
|
112
|
+
"type": "table",
|
|
113
|
+
"title": None,
|
|
114
|
+
"props": {
|
|
115
|
+
"columns": [{"key": c, "label": c.replace("_", " ").title()} for c in columns],
|
|
116
|
+
"rows": rows,
|
|
117
|
+
},
|
|
118
|
+
}]
|
|
119
|
+
|
|
120
|
+
return [_text_to_block(
|
|
121
|
+
json.dumps(data, indent=2, ensure_ascii=False),
|
|
122
|
+
f"{block_id_prefix}_fallback",
|
|
123
|
+
)]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_message_content(content: str | list[Any]) -> str:
|
|
127
|
+
"""Extract plain text from a LangChain message content field.
|
|
128
|
+
|
|
129
|
+
Content can be a plain string or a list of typed dicts
|
|
130
|
+
(e.g. [{"type": "text", "text": "…"}, {"type": "image_url", …}]).
|
|
131
|
+
"""
|
|
132
|
+
if isinstance(content, str):
|
|
133
|
+
return content
|
|
134
|
+
parts: list[str] = []
|
|
135
|
+
for item in content:
|
|
136
|
+
if isinstance(item, dict):
|
|
137
|
+
if item.get("type") == "text":
|
|
138
|
+
parts.append(item.get("text", ""))
|
|
139
|
+
elif item.get("type") == "image_url":
|
|
140
|
+
parts.append("[image]")
|
|
141
|
+
elif isinstance(item, str):
|
|
142
|
+
parts.append(item)
|
|
143
|
+
return "\n".join(parts)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _spec(
|
|
147
|
+
blocks: list[dict[str, Any]],
|
|
148
|
+
title: str,
|
|
149
|
+
run_id: str | None,
|
|
150
|
+
provenance_id: str,
|
|
151
|
+
) -> dict[str, Any]:
|
|
152
|
+
effective_blocks = blocks or [_text_to_block("No output captured.", "lc_empty")]
|
|
153
|
+
result: dict[str, Any] = {
|
|
154
|
+
"version": "tilo/aip/v1",
|
|
155
|
+
"title": title,
|
|
156
|
+
"status": "ready",
|
|
157
|
+
"blocks": effective_blocks,
|
|
158
|
+
"views": [
|
|
159
|
+
{
|
|
160
|
+
"id": "result",
|
|
161
|
+
"label": "Result",
|
|
162
|
+
"block_ids": [b["id"] for b in effective_blocks],
|
|
163
|
+
}
|
|
164
|
+
],
|
|
165
|
+
"actions": [],
|
|
166
|
+
"provenance": [{"type": "langchain_chain", "id": provenance_id}],
|
|
167
|
+
"memory_refs": [],
|
|
168
|
+
"follow_ups": [],
|
|
169
|
+
}
|
|
170
|
+
if run_id:
|
|
171
|
+
result["run_id"] = run_id
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --------------------------------------------------------------------------- #
|
|
176
|
+
# Public: direct conversion #
|
|
177
|
+
# --------------------------------------------------------------------------- #
|
|
178
|
+
|
|
179
|
+
def langchain_result_to_spec(
|
|
180
|
+
chain_name: str,
|
|
181
|
+
outputs: dict[str, Any],
|
|
182
|
+
*,
|
|
183
|
+
run_id: str | None = None,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
"""Convert a LangChain chain output dict directly into a Tilo AIP v1 spec.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
chain_name: Name of the chain (used as spec title and provenance).
|
|
189
|
+
outputs: The dict returned by ``chain.invoke()``.
|
|
190
|
+
run_id: Optional Tilo run_id to embed in the spec.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
A Tilo AIP v1 spec dict ready for use with ArtifactSpecV1.model_validate().
|
|
194
|
+
"""
|
|
195
|
+
blocks: list[dict[str, Any]] = []
|
|
196
|
+
for key, value in outputs.items():
|
|
197
|
+
if isinstance(value, str):
|
|
198
|
+
if value.strip():
|
|
199
|
+
blocks.append(_text_to_block(value, f"lc_out_{key}"))
|
|
200
|
+
elif isinstance(value, (dict, list)):
|
|
201
|
+
blocks.extend(_structured_output_to_blocks(value, f"lc_out_{key}"))
|
|
202
|
+
else:
|
|
203
|
+
blocks.append(_text_to_block(str(value), f"lc_out_{key}"))
|
|
204
|
+
|
|
205
|
+
return _spec(blocks, chain_name, run_id, chain_name)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --------------------------------------------------------------------------- #
|
|
209
|
+
# Public: callback handler #
|
|
210
|
+
# --------------------------------------------------------------------------- #
|
|
211
|
+
|
|
212
|
+
class TiloCallbackHandler:
|
|
213
|
+
"""LangChain callback handler that accumulates output as Tilo AIP blocks.
|
|
214
|
+
|
|
215
|
+
Duck-typed — no langchain import required. Compatible with:
|
|
216
|
+
- LangChain v0.1+ (``BaseCallbackHandler`` interface)
|
|
217
|
+
- LangGraph nodes that emit callbacks
|
|
218
|
+
- Any object that calls these methods
|
|
219
|
+
|
|
220
|
+
Example:
|
|
221
|
+
handler = TiloCallbackHandler(run_id="tilo-run-123")
|
|
222
|
+
chain.invoke(input, config={"callbacks": [handler]})
|
|
223
|
+
spec = handler.to_spec()
|
|
224
|
+
validated = ArtifactSpecV1.model_validate(spec)
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
def __init__(
|
|
228
|
+
self,
|
|
229
|
+
run_id: str | None = None,
|
|
230
|
+
title: str = "LangChain Result",
|
|
231
|
+
) -> None:
|
|
232
|
+
self.run_id = run_id
|
|
233
|
+
self.title = title
|
|
234
|
+
self.blocks: list[dict[str, Any]] = []
|
|
235
|
+
self._tool_names: dict[str, str] = {} # callback run_id → tool name
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------ #
|
|
238
|
+
# LLM callbacks #
|
|
239
|
+
# ------------------------------------------------------------------ #
|
|
240
|
+
|
|
241
|
+
def on_chat_model_start(
|
|
242
|
+
self,
|
|
243
|
+
serialized: dict[str, Any],
|
|
244
|
+
messages: list[Any],
|
|
245
|
+
**kwargs: Any,
|
|
246
|
+
) -> None:
|
|
247
|
+
"""No-op — output captured in on_llm_end."""
|
|
248
|
+
|
|
249
|
+
def on_llm_end(self, response: Any, **kwargs: Any) -> None:
|
|
250
|
+
"""Capture LLM text output as a markdown block."""
|
|
251
|
+
try:
|
|
252
|
+
generation = response.generations[0][0]
|
|
253
|
+
text: str = getattr(generation, "text", "") or ""
|
|
254
|
+
if not text and hasattr(generation, "message"):
|
|
255
|
+
text = _parse_message_content(
|
|
256
|
+
getattr(generation.message, "content", "")
|
|
257
|
+
)
|
|
258
|
+
if text.strip():
|
|
259
|
+
self.blocks.append(_text_to_block(text, _new_id("lc_llm")))
|
|
260
|
+
except (AttributeError, IndexError):
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------ #
|
|
264
|
+
# Tool callbacks #
|
|
265
|
+
# ------------------------------------------------------------------ #
|
|
266
|
+
|
|
267
|
+
def on_tool_start(
|
|
268
|
+
self,
|
|
269
|
+
serialized: dict[str, Any],
|
|
270
|
+
input_str: str,
|
|
271
|
+
**kwargs: Any,
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Record tool name so on_tool_end can label the block."""
|
|
274
|
+
cb_run_id = str(kwargs.get("run_id", ""))
|
|
275
|
+
tool_name = serialized.get("name", "tool")
|
|
276
|
+
if cb_run_id:
|
|
277
|
+
self._tool_names[cb_run_id] = tool_name
|
|
278
|
+
|
|
279
|
+
def on_tool_end(self, output: Any, **kwargs: Any) -> None:
|
|
280
|
+
"""Capture tool output as a tool_preview block."""
|
|
281
|
+
cb_run_id = str(kwargs.get("run_id", ""))
|
|
282
|
+
tool_name = self._tool_names.pop(cb_run_id, "tool")
|
|
283
|
+
self.blocks.append(
|
|
284
|
+
_tool_result_to_block(tool_name, str(output), _new_id("lc_tool"))
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def on_tool_error(self, error: Exception, **kwargs: Any) -> None:
|
|
288
|
+
"""Capture tool error as a tool_preview block with error status."""
|
|
289
|
+
cb_run_id = str(kwargs.get("run_id", ""))
|
|
290
|
+
tool_name = self._tool_names.pop(cb_run_id, "tool")
|
|
291
|
+
self.blocks.append(
|
|
292
|
+
_tool_result_to_block(
|
|
293
|
+
tool_name, str(error), _new_id("lc_tool_err"), is_error=True
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# ------------------------------------------------------------------ #
|
|
298
|
+
# Agent callbacks #
|
|
299
|
+
# ------------------------------------------------------------------ #
|
|
300
|
+
|
|
301
|
+
def on_agent_finish(self, finish: Any, **kwargs: Any) -> None:
|
|
302
|
+
"""Capture the final agent answer, avoiding duplicates from on_llm_end."""
|
|
303
|
+
try:
|
|
304
|
+
return_values: dict[str, Any] = getattr(finish, "return_values", {})
|
|
305
|
+
output = return_values.get("output", "") if isinstance(return_values, dict) else ""
|
|
306
|
+
if not (output and isinstance(output, str)):
|
|
307
|
+
return
|
|
308
|
+
already = any(
|
|
309
|
+
b.get("props", {}).get("content", "").strip() == output.strip()
|
|
310
|
+
for b in self.blocks
|
|
311
|
+
if b.get("type") == "markdown"
|
|
312
|
+
)
|
|
313
|
+
if not already:
|
|
314
|
+
self.blocks.append(_text_to_block(output, _new_id("lc_agent")))
|
|
315
|
+
except AttributeError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
# ------------------------------------------------------------------ #
|
|
319
|
+
# Chain callbacks #
|
|
320
|
+
# ------------------------------------------------------------------ #
|
|
321
|
+
|
|
322
|
+
def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
|
|
323
|
+
"""Fallback: capture structured chain output if no blocks exist yet."""
|
|
324
|
+
if self.blocks:
|
|
325
|
+
return # finer-grained callbacks already captured content
|
|
326
|
+
if not isinstance(outputs, dict):
|
|
327
|
+
return
|
|
328
|
+
for key, value in outputs.items():
|
|
329
|
+
if isinstance(value, str) and value.strip():
|
|
330
|
+
self.blocks.append(_text_to_block(value, f"lc_chain_{key}"))
|
|
331
|
+
elif isinstance(value, (dict, list)):
|
|
332
|
+
self.blocks.extend(
|
|
333
|
+
_structured_output_to_blocks(value, f"lc_chain_{key}")
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# ------------------------------------------------------------------ #
|
|
337
|
+
# Output #
|
|
338
|
+
# ------------------------------------------------------------------ #
|
|
339
|
+
|
|
340
|
+
def to_spec(self) -> dict[str, Any]:
|
|
341
|
+
"""Assemble all captured blocks into a Tilo AIP v1 spec dict."""
|
|
342
|
+
return _spec(self.blocks, self.title, self.run_id, self.title)
|
|
343
|
+
|
|
344
|
+
def reset(self) -> None:
|
|
345
|
+
"""Clear all captured state. Allows reuse across multiple chain runs."""
|
|
346
|
+
self.blocks.clear()
|
|
347
|
+
self._tool_names.clear()
|
tilo/adapters/mcp.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""MCP → Tilo AIP adapter.
|
|
2
|
+
|
|
3
|
+
Converts MCP (Model Context Protocol) tool results into Tilo ArtifactBlocks.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from tilo.adapters.mcp import mcp_content_to_blocks
|
|
7
|
+
|
|
8
|
+
blocks = mcp_content_to_blocks(mcp_result.content)
|
|
9
|
+
|
|
10
|
+
Mapping:
|
|
11
|
+
TextContent → markdown block
|
|
12
|
+
ImageContent → image block
|
|
13
|
+
EmbeddedResource → card block with metadata
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def mcp_content_to_blocks(content: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
20
|
+
"""Convert a list of MCP Content items to Tilo ArtifactBlocks.
|
|
21
|
+
|
|
22
|
+
MCP Content follows the spec at https://modelcontextprotocol.io
|
|
23
|
+
Each item has a "type" field: "text", "image", or "resource".
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
content: List of MCP Content dicts, each with at minimum a "type" field.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
List of Tilo ArtifactBlock dicts ready for inclusion in a spec.
|
|
30
|
+
"""
|
|
31
|
+
blocks: list[dict[str, Any]] = []
|
|
32
|
+
for i, item in enumerate(content):
|
|
33
|
+
content_type = item.get("type", "text")
|
|
34
|
+
block_id = f"mcp_{i}"
|
|
35
|
+
|
|
36
|
+
if content_type == "text":
|
|
37
|
+
blocks.append({
|
|
38
|
+
"id": block_id,
|
|
39
|
+
"type": "markdown",
|
|
40
|
+
"title": None,
|
|
41
|
+
"props": {
|
|
42
|
+
"content": item.get("text", ""),
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
elif content_type == "image":
|
|
47
|
+
blocks.append({
|
|
48
|
+
"id": block_id,
|
|
49
|
+
"type": "image",
|
|
50
|
+
"title": None,
|
|
51
|
+
"props": {
|
|
52
|
+
"src": item.get("data", ""),
|
|
53
|
+
"alt": item.get("mimeType", "image"),
|
|
54
|
+
"mime_type": item.get("mimeType", "image/png"),
|
|
55
|
+
"encoding": "base64",
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
elif content_type == "resource":
|
|
60
|
+
resource = item.get("resource", {})
|
|
61
|
+
blocks.append({
|
|
62
|
+
"id": block_id,
|
|
63
|
+
"type": "card",
|
|
64
|
+
"title": resource.get("name") or resource.get("uri", "Resource"),
|
|
65
|
+
"props": {
|
|
66
|
+
"title": resource.get("name") or "Embedded Resource",
|
|
67
|
+
"content": resource.get("text", ""),
|
|
68
|
+
"uri": resource.get("uri"),
|
|
69
|
+
"mime_type": resource.get("mimeType"),
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
else:
|
|
74
|
+
# Unknown MCP content type → generic block
|
|
75
|
+
blocks.append({
|
|
76
|
+
"id": block_id,
|
|
77
|
+
"type": "card",
|
|
78
|
+
"title": f"MCP {content_type}",
|
|
79
|
+
"props": item,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return blocks
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def mcp_tool_result_to_spec(
|
|
86
|
+
tool_name: str,
|
|
87
|
+
content: list[dict[str, Any]],
|
|
88
|
+
*,
|
|
89
|
+
is_error: bool = False,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
"""Convert a complete MCP tool result into a minimal Tilo AIP spec.
|
|
92
|
+
|
|
93
|
+
This creates a ready-to-render spec with a single "Result" view.
|
|
94
|
+
"""
|
|
95
|
+
blocks = mcp_content_to_blocks(content)
|
|
96
|
+
|
|
97
|
+
if is_error:
|
|
98
|
+
blocks.insert(0, {
|
|
99
|
+
"id": "mcp_error",
|
|
100
|
+
"type": "card",
|
|
101
|
+
"title": "Tool Error",
|
|
102
|
+
"props": {
|
|
103
|
+
"title": f"Error from {tool_name}",
|
|
104
|
+
"content": "The tool returned an error. See details below.",
|
|
105
|
+
"severity": "high",
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"version": "tilo/aip/v1",
|
|
111
|
+
"title": f"{tool_name} Result",
|
|
112
|
+
"status": "ready",
|
|
113
|
+
"blocks": blocks,
|
|
114
|
+
"views": [
|
|
115
|
+
{
|
|
116
|
+
"id": "result",
|
|
117
|
+
"label": "Result",
|
|
118
|
+
"block_ids": [b["id"] for b in blocks],
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
"actions": [],
|
|
122
|
+
"provenance": [],
|
|
123
|
+
"memory_refs": [],
|
|
124
|
+
"follow_ups": [],
|
|
125
|
+
}
|
tilo/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
tilo/api/deps.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Any, TypeVar
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException
|
|
4
|
+
from sqlalchemy.orm import Session
|
|
5
|
+
|
|
6
|
+
ModelT = TypeVar("ModelT")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_one(db: Session, model: type[ModelT], item_id: str) -> ModelT:
|
|
10
|
+
item = db.get(model, item_id)
|
|
11
|
+
if not item:
|
|
12
|
+
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
|
13
|
+
return item
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def apply_update(item: Any, patch: dict[str, Any]) -> Any:
|
|
17
|
+
for key, value in patch.items():
|
|
18
|
+
if hasattr(item, key) and key not in {"id", "created_at"}:
|
|
19
|
+
setattr(item, key, value)
|
|
20
|
+
return item
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from tilo.api.routes import agents, apps, artifacts, channels, confirmations, conversations, demo, feedback, interactions, memories, messages, projects, runs, skills, system, tasks, tools, workspaces
|
|
2
|
+
|
|
3
|
+
routers = [
|
|
4
|
+
system.router,
|
|
5
|
+
apps.router,
|
|
6
|
+
workspaces.router,
|
|
7
|
+
projects.router,
|
|
8
|
+
agents.router,
|
|
9
|
+
tasks.router,
|
|
10
|
+
runs.router,
|
|
11
|
+
messages.router,
|
|
12
|
+
conversations.router,
|
|
13
|
+
channels.router,
|
|
14
|
+
interactions.router,
|
|
15
|
+
feedback.router,
|
|
16
|
+
memories.router,
|
|
17
|
+
artifacts.router,
|
|
18
|
+
confirmations.router,
|
|
19
|
+
demo.router,
|
|
20
|
+
skills.router,
|
|
21
|
+
tools.router,
|
|
22
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from tilo.api.deps import apply_update, get_one
|
|
9
|
+
from tilo.core.database import get_db
|
|
10
|
+
from tilo.models import Agent
|
|
11
|
+
from tilo.schemas import AgentCreate, AgentRead
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/api/agents", tags=["agents"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("", response_model=list[AgentRead])
|
|
17
|
+
def list_agents(workspace_id: str, db: Session = Depends(get_db)) -> Sequence[Agent]:
|
|
18
|
+
return db.scalars(select(Agent).where(Agent.workspace_id == workspace_id).order_by(Agent.created_at)).all()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.post("", response_model=AgentRead)
|
|
22
|
+
def create_agent(payload: AgentCreate, db: Session = Depends(get_db)) -> Agent:
|
|
23
|
+
item = Agent(**payload.model_dump())
|
|
24
|
+
db.add(item)
|
|
25
|
+
db.commit()
|
|
26
|
+
db.refresh(item)
|
|
27
|
+
return item
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/{item_id}", response_model=AgentRead)
|
|
31
|
+
def read_agent(item_id: str, db: Session = Depends(get_db)) -> Agent:
|
|
32
|
+
return get_one(db, Agent, item_id)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.patch("/{item_id}", response_model=AgentRead)
|
|
36
|
+
def update_agent(item_id: str, payload: dict[str, Any], db: Session = Depends(get_db)) -> Agent:
|
|
37
|
+
item = apply_update(get_one(db, Agent, item_id), payload)
|
|
38
|
+
db.commit()
|
|
39
|
+
db.refresh(item)
|
|
40
|
+
return item
|
tilo/api/routes/apps.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from sqlalchemy.orm import Session
|
|
4
|
+
|
|
5
|
+
from tilo.core.database import get_db
|
|
6
|
+
from tilo.services.agent_context import AgentContextBuilder
|
|
7
|
+
from tilo.services.apps import AgentAppManifest
|
|
8
|
+
from tilo.services.apps.loader import get_app_loader
|
|
9
|
+
from tilo.services.interaction_policy.schemas import InteractionContext, InteractionDecision
|
|
10
|
+
from tilo.services.interaction_policy.service import InteractionPolicyService
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/api/apps", tags=["apps"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentContextBuildRequest(BaseModel):
|
|
16
|
+
workspace_id: str
|
|
17
|
+
project_id: str | None = None
|
|
18
|
+
artifact_id: str | None = None
|
|
19
|
+
policy_context: InteractionContext | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("", response_model=list[AgentAppManifest])
|
|
23
|
+
def list_apps() -> list[AgentAppManifest]:
|
|
24
|
+
return get_app_loader().list_apps()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/{app_id}", response_model=AgentAppManifest)
|
|
28
|
+
def read_app(app_id: str) -> AgentAppManifest:
|
|
29
|
+
try:
|
|
30
|
+
return get_app_loader().load_manifest(app_id)
|
|
31
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
32
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.post("/{app_id}/interaction-policy/evaluate", response_model=InteractionDecision)
|
|
36
|
+
def evaluate_app_interaction_policy(app_id: str, context: InteractionContext) -> InteractionDecision:
|
|
37
|
+
try:
|
|
38
|
+
return InteractionPolicyService().evaluate_for_app(app_id, context)
|
|
39
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
40
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.post("/{app_id}/agent-context")
|
|
44
|
+
def build_agent_context(app_id: str, payload: AgentContextBuildRequest, db: Session = Depends(get_db)) -> dict:
|
|
45
|
+
try:
|
|
46
|
+
get_app_loader().load_manifest(app_id)
|
|
47
|
+
return AgentContextBuilder(db).build(
|
|
48
|
+
app_id=app_id,
|
|
49
|
+
workspace_id=payload.workspace_id,
|
|
50
|
+
project_id=payload.project_id,
|
|
51
|
+
artifact_id=payload.artifact_id,
|
|
52
|
+
policy_context=payload.policy_context,
|
|
53
|
+
)
|
|
54
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
55
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|