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.
Files changed (114) hide show
  1. tilo/__init__.py +1 -0
  2. tilo/adapters/__init__.py +5 -0
  3. tilo/adapters/a2a.py +29 -0
  4. tilo/adapters/acp.py +29 -0
  5. tilo/adapters/langchain.py +347 -0
  6. tilo/adapters/mcp.py +125 -0
  7. tilo/api/__init__.py +1 -0
  8. tilo/api/deps.py +20 -0
  9. tilo/api/routes/__init__.py +22 -0
  10. tilo/api/routes/agents.py +40 -0
  11. tilo/api/routes/apps.py +55 -0
  12. tilo/api/routes/artifacts.py +83 -0
  13. tilo/api/routes/channels.py +25 -0
  14. tilo/api/routes/confirmations.py +57 -0
  15. tilo/api/routes/conversations.py +142 -0
  16. tilo/api/routes/demo.py +19 -0
  17. tilo/api/routes/feedback.py +34 -0
  18. tilo/api/routes/interactions.py +58 -0
  19. tilo/api/routes/memories.py +146 -0
  20. tilo/api/routes/messages.py +52 -0
  21. tilo/api/routes/projects.py +40 -0
  22. tilo/api/routes/runs.py +42 -0
  23. tilo/api/routes/skills.py +104 -0
  24. tilo/api/routes/system.py +44 -0
  25. tilo/api/routes/tasks.py +54 -0
  26. tilo/api/routes/tools.py +67 -0
  27. tilo/api/routes/workspaces.py +40 -0
  28. tilo/cli.py +107 -0
  29. tilo/core/__init__.py +1 -0
  30. tilo/core/config.py +46 -0
  31. tilo/core/database.py +24 -0
  32. tilo/core/migrations.py +67 -0
  33. tilo/core/time.py +5 -0
  34. tilo/main.py +93 -0
  35. tilo/models/__init__.py +53 -0
  36. tilo/models/domain.py +404 -0
  37. tilo/schemas/__init__.py +3 -0
  38. tilo/schemas/artifact.py +241 -0
  39. tilo/schemas/domain.py +533 -0
  40. tilo/schemas/surface.py +504 -0
  41. tilo/services/__init__.py +1 -0
  42. tilo/services/agent_context/__init__.py +3 -0
  43. tilo/services/agent_context/builder.py +127 -0
  44. tilo/services/agent_runtime/__init__.py +3 -0
  45. tilo/services/agent_runtime/executor.py +38 -0
  46. tilo/services/agent_runtime/message_flow.py +116 -0
  47. tilo/services/agent_runtime/planner.py +99 -0
  48. tilo/services/agent_runtime/prompt_builder.py +72 -0
  49. tilo/services/agent_runtime/run_manager.py +457 -0
  50. tilo/services/agent_runtime/state_machine.py +87 -0
  51. tilo/services/apps/__init__.py +4 -0
  52. tilo/services/apps/loader.py +88 -0
  53. tilo/services/apps/schemas.py +48 -0
  54. tilo/services/artifact/__init__.py +3 -0
  55. tilo/services/artifact/actions.py +523 -0
  56. tilo/services/artifact/aip_generator.py +887 -0
  57. tilo/services/artifact/contract_llm.py +114 -0
  58. tilo/services/artifact/generator.py +296 -0
  59. tilo/services/artifact/persistence.py +44 -0
  60. tilo/services/artifact/spec.py +923 -0
  61. tilo/services/bootstrap.py +50 -0
  62. tilo/services/channels/__init__.py +2 -0
  63. tilo/services/channels/telegram/__init__.py +4 -0
  64. tilo/services/channels/telegram/adapter.py +81 -0
  65. tilo/services/channels/telegram/renderer.py +59 -0
  66. tilo/services/channels/telegram/types.py +18 -0
  67. tilo/services/channels/telegram/webhook.py +259 -0
  68. tilo/services/channels/types.py +64 -0
  69. tilo/services/context_reflection/__init__.py +11 -0
  70. tilo/services/context_reflection/schemas.py +31 -0
  71. tilo/services/context_reflection/service.py +224 -0
  72. tilo/services/conversations/__init__.py +3 -0
  73. tilo/services/conversations/constants.py +25 -0
  74. tilo/services/conversations/messages.py +121 -0
  75. tilo/services/conversations/service.py +190 -0
  76. tilo/services/demo/__init__.py +6 -0
  77. tilo/services/demo/contracts.py +41 -0
  78. tilo/services/improvement/__init__.py +4 -0
  79. tilo/services/improvement/candidates.py +144 -0
  80. tilo/services/improvement/metrics.py +50 -0
  81. tilo/services/inbox/__init__.py +3 -0
  82. tilo/services/inbox/confirmations.py +77 -0
  83. tilo/services/interaction_policy/__init__.py +3 -0
  84. tilo/services/interaction_policy/schemas.py +125 -0
  85. tilo/services/interaction_policy/service.py +161 -0
  86. tilo/services/interactions/__init__.py +1 -0
  87. tilo/services/interactions/events.py +51 -0
  88. tilo/services/memory/__init__.py +4 -0
  89. tilo/services/memory/behaviour.py +320 -0
  90. tilo/services/memory/extraction.py +124 -0
  91. tilo/services/memory/recall.py +209 -0
  92. tilo/services/memory/writer.py +160 -0
  93. tilo/services/models/__init__.py +11 -0
  94. tilo/services/models/client.py +416 -0
  95. tilo/services/models/errors.py +18 -0
  96. tilo/services/models/prompts.py +105 -0
  97. tilo/services/models/schemas.py +129 -0
  98. tilo/services/skill/__init__.py +3 -0
  99. tilo/services/skill/selector.py +27 -0
  100. tilo/services/surface/__init__.py +23 -0
  101. tilo/services/surface/composer.py +519 -0
  102. tilo/services/surface/persistence.py +145 -0
  103. tilo/services/surfaces/__init__.py +3 -0
  104. tilo/services/surfaces/constants.py +13 -0
  105. tilo/services/surfaces/rich_links.py +31 -0
  106. tilo/services/tools/__init__.py +3 -0
  107. tilo/services/tools/invocation.py +129 -0
  108. tilo/services/tools/registry.py +40 -0
  109. tilo/services/trace/__init__.py +3 -0
  110. tilo/services/trace/recorder.py +160 -0
  111. tilo-0.1.0.dist-info/METADATA +24 -0
  112. tilo-0.1.0.dist-info/RECORD +114 -0
  113. tilo-0.1.0.dist-info/WHEEL +4 -0
  114. tilo-0.1.0.dist-info/entry_points.txt +2 -0
tilo/__init__.py ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,5 @@
1
+ """Tilo Protocol Adapters — bridges between external protocols and Tilo AIP.
2
+
3
+ Each adapter converts protocol-specific output into Tilo ArtifactBlocks,
4
+ enabling zero-code integration with the Tilo Canvas.
5
+ """
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
@@ -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