sofias-sdk-lite 0.2.1__tar.gz → 0.4.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.
Files changed (84) hide show
  1. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/PKG-INFO +11 -6
  2. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/README.md +10 -5
  3. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/pyproject.toml +1 -1
  4. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/pyproject.toml.orig +1 -1
  5. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/__init__.py +11 -0
  6. sofias_sdk_lite-0.4.0/src/sofias_sdk_lite/files.py +195 -0
  7. sofias_sdk_lite-0.4.0/src/sofias_sdk_lite/mcp.py +240 -0
  8. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/messaging/models.py +10 -0
  9. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/llm_node.py +20 -1
  10. sofias_sdk_lite-0.4.0/src/sofias_sdk_lite/observability/tool_events.py +80 -0
  11. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/LICENSE +0 -0
  12. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/agent/__init__.py +0 -0
  13. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/agent/agent.py +0 -0
  14. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/agent/agent_builder.py +0 -0
  15. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/agent/agent_config.py +0 -0
  16. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/agent/execution_context.py +0 -0
  17. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/agent/middleware.py +0 -0
  18. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/agent/response_workflow.py +0 -0
  19. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/agent/streaming.py +0 -0
  20. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/config/__init__.py +0 -0
  21. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/config/agent_settings.py +0 -0
  22. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/config/defaults.py +0 -0
  23. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/config/runtime_config.py +0 -0
  24. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/config/sdk_config.py +0 -0
  25. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/config/source.py +0 -0
  26. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/contracts/__init__.py +0 -0
  27. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/contracts/agent_contracts.py +0 -0
  28. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/contracts/base_contracts.py +0 -0
  29. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/contracts/node_contracts.py +0 -0
  30. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/contracts/strict_contract.py +0 -0
  31. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/errors/__init__.py +0 -0
  32. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/errors/circuit_breaker.py +0 -0
  33. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/errors/error_handler.py +0 -0
  34. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/errors/exceptions.py +0 -0
  35. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/llm/__init__.py +0 -0
  36. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/llm/factory.py +0 -0
  37. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/llm/gateway.py +0 -0
  38. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/llm/openai_compatible.py +0 -0
  39. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/llm/protocol.py +0 -0
  40. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/llm/tokens.py +0 -0
  41. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/llm/tools.py +0 -0
  42. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/messaging/__init__.py +0 -0
  43. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/__init__.py +0 -0
  44. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/aggregator_config.py +0 -0
  45. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/aggregator_node.py +0 -0
  46. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/base_node.py +0 -0
  47. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/delegation_config.py +0 -0
  48. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/delegation_node.py +0 -0
  49. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/delegation_transport.py +0 -0
  50. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/function_node.py +0 -0
  51. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/llm_node_config.py +0 -0
  52. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/plan_executor.py +0 -0
  53. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/planner_node.py +0 -0
  54. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/planning_models.py +0 -0
  55. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/nodes/prompt_assembler.py +0 -0
  56. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/observability/__init__.py +0 -0
  57. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/observability/_log.py +0 -0
  58. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/observability/events.py +0 -0
  59. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/observability/trace_context.py +0 -0
  60. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/observability/tracing.py +0 -0
  61. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/py.typed +0 -0
  62. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/rabbitmq/__init__.py +0 -0
  63. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/rabbitmq/client.py +0 -0
  64. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/rabbitmq/config.py +0 -0
  65. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/rabbitmq/consumer.py +0 -0
  66. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/rabbitmq/delegation_transport.py +0 -0
  67. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/rabbitmq/publisher.py +0 -0
  68. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/rabbitmq/rpc_client.py +0 -0
  69. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/rabbitmq/types.py +0 -0
  70. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/routing/__init__.py +0 -0
  71. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/routing/router.py +0 -0
  72. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/routing/strategies.py +0 -0
  73. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/runner/__init__.py +0 -0
  74. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/runner/config.py +0 -0
  75. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/runner/runner.py +0 -0
  76. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/state/__init__.py +0 -0
  77. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/state/conversation_state.py +0 -0
  78. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/state/history.py +0 -0
  79. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/state/memory.py +0 -0
  80. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/tools.py +0 -0
  81. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/workflows/__init__.py +0 -0
  82. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/workflows/chat.py +0 -0
  83. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/workflows/null.py +0 -0
  84. {sofias_sdk_lite-0.2.1 → sofias_sdk_lite-0.4.0}/src/sofias_sdk_lite/workflows/streaming_chat.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sofias-sdk-lite
3
- Version: 0.2.1
3
+ Version: 0.4.0
4
4
  Summary: Build agents on the Sofias agent graph and run them over RabbitMQ.
5
5
  Keywords: agents,ai,llm,rabbitmq,sofias
6
6
  Author: Sofias Tech, S.L.
@@ -56,6 +56,11 @@ Build multi-node agents on a declarative graph and run them against RabbitMQ.
56
56
  from the agent's settings or the `SOFIAS_LLM_*` environment. `create_llm()`
57
57
  builds the client when you need it explicitly; `LLMCallable` stays a protocol
58
58
  for anything the bundled clients do not cover.
59
+ - **The user's connectors** — `McpToolProvider` turns the connectors the platform
60
+ sends with each turn (mail, documents, CRM, the company's own MCP servers…)
61
+ into tools for your LLM nodes. They go through the platform's intermediary:
62
+ your agent never sees a credential. See
63
+ [Using the user's connectors](https://docs.sofias.ai/guides/connectors/).
59
64
 
60
65
  Nothing here depends on a private backend or an internal config service.
61
66
  Where the model lives is deployment configuration, not agent code.
@@ -209,11 +214,11 @@ if __name__ == "__main__":
209
214
 
210
215
  ## Scope (v1)
211
216
 
212
- Core agent graph, RabbitMQ messaging, the runner, and LLM clients for the
213
- Sofias gateway / any OpenAI-compatible endpoint ship today. Memory and MCP
214
- tool discovery are intentionally out of scope for v1 — the SDK ships the
215
- relevant protocols (`MemoryProvider`, `ToolProvider`) so you can plug in
216
- your own, and these become optional extras in a later release.
217
+ Core agent graph, RabbitMQ messaging, the runner, LLM clients for the Sofias
218
+ gateway / any OpenAI-compatible endpoint and, since 0.3.0, the user's
219
+ connectors (MCP) ship today. Memory is intentionally out of scope for v1 — the
220
+ SDK ships the `MemoryProvider` protocol so you can plug in your own, and it
221
+ becomes an optional extra in a later release.
217
222
 
218
223
  ## Source and contributions
219
224
 
@@ -20,6 +20,11 @@ Build multi-node agents on a declarative graph and run them against RabbitMQ.
20
20
  from the agent's settings or the `SOFIAS_LLM_*` environment. `create_llm()`
21
21
  builds the client when you need it explicitly; `LLMCallable` stays a protocol
22
22
  for anything the bundled clients do not cover.
23
+ - **The user's connectors** — `McpToolProvider` turns the connectors the platform
24
+ sends with each turn (mail, documents, CRM, the company's own MCP servers…)
25
+ into tools for your LLM nodes. They go through the platform's intermediary:
26
+ your agent never sees a credential. See
27
+ [Using the user's connectors](https://docs.sofias.ai/guides/connectors/).
23
28
 
24
29
  Nothing here depends on a private backend or an internal config service.
25
30
  Where the model lives is deployment configuration, not agent code.
@@ -173,11 +178,11 @@ if __name__ == "__main__":
173
178
 
174
179
  ## Scope (v1)
175
180
 
176
- Core agent graph, RabbitMQ messaging, the runner, and LLM clients for the
177
- Sofias gateway / any OpenAI-compatible endpoint ship today. Memory and MCP
178
- tool discovery are intentionally out of scope for v1 — the SDK ships the
179
- relevant protocols (`MemoryProvider`, `ToolProvider`) so you can plug in
180
- your own, and these become optional extras in a later release.
181
+ Core agent graph, RabbitMQ messaging, the runner, LLM clients for the Sofias
182
+ gateway / any OpenAI-compatible endpoint and, since 0.3.0, the user's
183
+ connectors (MCP) ship today. Memory is intentionally out of scope for v1 — the
184
+ SDK ships the `MemoryProvider` protocol so you can plug in your own, and it
185
+ becomes an optional extra in a later release.
181
186
 
182
187
  ## Source and contributions
183
188
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sofias-sdk-lite"
3
- version = "0.2.1"
3
+ version = "0.4.0"
4
4
  description = "Build agents on the Sofias agent graph and run them over RabbitMQ."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sofias-sdk-lite"
3
- version = "0.2.1"
3
+ version = "0.4.0"
4
4
  description = "Build agents on the Sofias agent graph and run them over RabbitMQ."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -103,6 +103,12 @@ from sofias_sdk_lite.llm import (
103
103
  ToolCall,
104
104
  ToolSpec,
105
105
  )
106
+ from sofias_sdk_lite.files import (
107
+ Attachment,
108
+ FileAccess,
109
+ FileAccessError,
110
+ ReturnedFile,
111
+ )
106
112
  from sofias_sdk_lite.messaging import (
107
113
  AgentTaskMessage,
108
114
  ApiTaskMessage,
@@ -171,6 +177,11 @@ except PackageNotFoundError: # pragma: no cover - source checkout without insta
171
177
 
172
178
  __all__ = [
173
179
  "__version__",
180
+ # Files (0.4.0)
181
+ "Attachment",
182
+ "FileAccess",
183
+ "FileAccessError",
184
+ "ReturnedFile",
174
185
  # Agent
175
186
  "Agent",
176
187
  "AgentBuilder",
@@ -0,0 +1,195 @@
1
+ """Files for third-party agents: the turn's attachments and the files you return.
2
+
3
+ Everything goes through the platform's intermediary with the turn's short-lived
4
+ grant (the same one your connectors use). Your agent never sees storage keys or
5
+ permanent links, and the grant expires when the turn does.
6
+
7
+ The task message carries what you need::
8
+
9
+ def prepare_input(self, task, history, role):
10
+ for attachment in task.attachment_details: # what the user attached
11
+ data = task.files.download(attachment) # bytes
12
+ saved = task.files.upload("report.csv", csv_bytes, "text/csv")
13
+ answer = f"Here is the report.\\n\\n{saved.artifact()}"
14
+
15
+ ``task.files`` is ``None`` when the platform has no intermediary for your agent
16
+ (for instance, a local run): check it before using it.
17
+
18
+ Images for a vision model::
19
+
20
+ part = task.files.image_part(attachment) # OpenAI-compatible content part
21
+ messages = [{"role": "user", "content": [{"type": "text", "text": "What is it?"}, part]}]
22
+
23
+ Limits: 25 MB per file and 10 returned files per turn.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import json
30
+ from typing import Any, ClassVar
31
+ from urllib.parse import quote
32
+
33
+ import httpx
34
+ from pydantic import BaseModel, ConfigDict, Field
35
+
36
+ __all__ = [
37
+ "MAX_FILE_BYTES",
38
+ "Attachment",
39
+ "FileAccess",
40
+ "FileAccessError",
41
+ "ReturnedFile",
42
+ ]
43
+
44
+ #: The intermediary rejects bigger files; checked here too, before sending.
45
+ MAX_FILE_BYTES = 25 * 1024 * 1024
46
+
47
+
48
+ class FileAccessError(RuntimeError):
49
+ """A download or upload the intermediary refused (status in ``status_code``)."""
50
+
51
+ def __init__(self, message: str, status_code: int | None = None) -> None:
52
+ super().__init__(message)
53
+ self.status_code = status_code
54
+
55
+
56
+ class Attachment(BaseModel):
57
+ """A file the user attached, as listed in ``task.attachment_details``."""
58
+
59
+ model_config = ConfigDict(extra="ignore")
60
+
61
+ id: str | None = None
62
+ filename: str
63
+ content_type: str | None = None
64
+ byte_size: int | None = None
65
+ url: str
66
+
67
+
68
+ class ReturnedFile(BaseModel):
69
+ """A file you uploaded with ``FileAccess.upload``: return it with ``artifact()``."""
70
+
71
+ model_config = ConfigDict(extra="ignore")
72
+
73
+ id: str
74
+ filename: str
75
+ content_type: str | None = None
76
+ byte_size: int | None = None
77
+ url: str | None = None
78
+
79
+ def artifact(self) -> str:
80
+ """The tag that hands this file to the user. Put it in your answer."""
81
+ return "[ARTIFACT]\n" + json.dumps({"file_id": self.id}) + "\n[/ARTIFACT]"
82
+
83
+
84
+ class FileAccess(BaseModel):
85
+ """Download the turn's attachments and upload the files you return.
86
+
87
+ Built by the SDK from the ``files`` entry of the task message; the token is
88
+ the turn's grant and never appears in ``repr`` or logs.
89
+ """
90
+
91
+ model_config = ConfigDict(extra="ignore")
92
+
93
+ url: str
94
+ token: str = Field(repr=False)
95
+ timeout: float = 60.0
96
+
97
+ #: Tests swap the HTTP transport here (``httpx.MockTransport``).
98
+ _transport: ClassVar[httpx.BaseTransport | None] = None
99
+ _async_transport: ClassVar[httpx.AsyncBaseTransport | None] = None
100
+
101
+ # -- download -----------------------------------------------------------
102
+
103
+ def download(self, attachment: Attachment | str) -> bytes:
104
+ """The bytes of an attachment (or of an intermediary file URL)."""
105
+ with httpx.Client(timeout=self.timeout, transport=self._transport) as client:
106
+ response = client.get(_url_of(attachment), headers=self._headers())
107
+ return _content(response, "download")
108
+
109
+ async def adownload(self, attachment: Attachment | str) -> bytes:
110
+ """Async ``download``."""
111
+ async with httpx.AsyncClient(
112
+ timeout=self.timeout, transport=self._async_transport
113
+ ) as client:
114
+ response = await client.get(_url_of(attachment), headers=self._headers())
115
+ return _content(response, "download")
116
+
117
+ def image_part(self, attachment: Attachment) -> dict[str, Any]:
118
+ """An OpenAI-compatible ``image_url`` content part for a vision model."""
119
+ return _image_part(attachment, self.download(attachment))
120
+
121
+ async def aimage_part(self, attachment: Attachment) -> dict[str, Any]:
122
+ """Async ``image_part``."""
123
+ return _image_part(attachment, await self.adownload(attachment))
124
+
125
+ # -- upload -------------------------------------------------------------
126
+
127
+ def upload(
128
+ self, filename: str, data: bytes, content_type: str = "application/octet-stream"
129
+ ) -> ReturnedFile:
130
+ """Store a file for this turn; return it to the user with ``.artifact()``."""
131
+ _check_size(data)
132
+ with httpx.Client(timeout=self.timeout, transport=self._transport) as client:
133
+ response = client.post(
134
+ self._upload_url(filename), content=data, headers=self._headers(content_type)
135
+ )
136
+ return _returned(response)
137
+
138
+ async def aupload(
139
+ self, filename: str, data: bytes, content_type: str = "application/octet-stream"
140
+ ) -> ReturnedFile:
141
+ """Async ``upload``."""
142
+ _check_size(data)
143
+ async with httpx.AsyncClient(
144
+ timeout=self.timeout, transport=self._async_transport
145
+ ) as client:
146
+ response = await client.post(
147
+ self._upload_url(filename), content=data, headers=self._headers(content_type)
148
+ )
149
+ return _returned(response)
150
+
151
+ # -- internals ----------------------------------------------------------
152
+
153
+ def _headers(self, content_type: str | None = None) -> dict[str, str]:
154
+ headers = {"Authorization": f"Bearer {self.token}"}
155
+ if content_type:
156
+ headers["Content-Type"] = content_type
157
+ return headers
158
+
159
+ def _upload_url(self, filename: str) -> str:
160
+ return f"{self.url.rstrip('/')}?filename={quote(filename)}"
161
+
162
+
163
+ def _url_of(attachment: Attachment | str) -> str:
164
+ return attachment if isinstance(attachment, str) else attachment.url
165
+
166
+
167
+ def _check_size(data: bytes) -> None:
168
+ if not data:
169
+ raise ValueError("cannot upload an empty file")
170
+ if len(data) > MAX_FILE_BYTES:
171
+ raise ValueError(f"file too large: {len(data)} bytes (limit {MAX_FILE_BYTES})")
172
+
173
+
174
+ def _content(response: httpx.Response, action: str) -> bytes:
175
+ if response.status_code != 200:
176
+ raise FileAccessError(
177
+ f"file {action} failed (HTTP {response.status_code})", response.status_code
178
+ )
179
+ return response.content
180
+
181
+
182
+ def _returned(response: httpx.Response) -> ReturnedFile:
183
+ if response.status_code != 201:
184
+ raise FileAccessError(
185
+ f"file upload failed (HTTP {response.status_code})", response.status_code
186
+ )
187
+ return ReturnedFile.model_validate(response.json())
188
+
189
+
190
+ def _image_part(attachment: Attachment, data: bytes) -> dict[str, Any]:
191
+ content_type = attachment.content_type or "image/png"
192
+ if not content_type.startswith("image/"):
193
+ raise ValueError(f"{attachment.filename} is not an image ({content_type})")
194
+ encoded = base64.b64encode(data).decode("ascii")
195
+ return {"type": "image_url", "image_url": {"url": f"data:{content_type};base64,{encoded}"}}
@@ -0,0 +1,240 @@
1
+ """MCP connectors delivered by the Sofias platform.
2
+
3
+ The platform sends each turn's connectors in ``AgentTaskMessage.mcp_servers``.
4
+ Inside ``configuration`` it puts the transport details under ``__mcp_*`` keys:
5
+
6
+ - ``__mcp_url``: the endpoint to call. For marketplace agents it is always the
7
+ platform's MCP intermediary, which adds the user's real credentials itself.
8
+ - ``__mcp_headers``: headers for every request (a short-lived grant). Never put
9
+ them in a prompt or a log.
10
+ - ``__mcp_tools``: the allow list approved for that connector.
11
+ - ``__mcp_namespace``: offer the tools as ``connector__tool`` so two connectors
12
+ with a tool of the same name do not clash.
13
+
14
+ ``McpToolProvider`` implements the ``ToolProvider`` protocol on top of those
15
+ entries with a minimal Streamable HTTP client (JSON-RPC over ``httpx``, JSON or
16
+ event-stream answers, ``Mcp-Session-Id``), so it needs no extra dependency::
17
+
18
+ from sofias_sdk_lite.mcp import McpToolProvider
19
+ from sofias_sdk_lite.tools import ToolRegistry
20
+
21
+ provider = McpToolProvider(task.mcp_servers)
22
+ builder.with_tool_registry(ToolRegistry(provider))
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+ from dataclasses import dataclass, field
30
+ from typing import Any
31
+
32
+ import httpx
33
+
34
+ from sofias_sdk_lite.llm.tools import ToolSpec
35
+ from sofias_sdk_lite.observability._log import get_logger
36
+
37
+ logger = get_logger("mcp")
38
+
39
+ __all__ = ["McpError", "McpServer", "McpToolProvider", "mcp_servers_from_message"]
40
+
41
+ PROTOCOL_VERSION = "2025-06-18"
42
+ _UNSUPPORTED = re.compile(r"[^A-Za-z0-9_-]")
43
+
44
+
45
+ class McpError(Exception):
46
+ """An MCP server answered with an error or could not be reached."""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class McpServer:
51
+ """One connector of the turn, ready to call."""
52
+
53
+ name: str
54
+ url: str
55
+ headers: dict[str, str] = field(default_factory=dict)
56
+ tools: tuple[str, ...] | None = None
57
+ namespace: bool = False
58
+
59
+
60
+ def _field(raw: Any, name: str) -> Any:
61
+ return raw.get(name) if isinstance(raw, dict) else getattr(raw, name, None)
62
+
63
+
64
+ def mcp_servers_from_message(raw_servers: list[Any]) -> list[McpServer]:
65
+ """Read the ``mcp_servers`` of a task message (models or plain dicts)."""
66
+ servers: list[McpServer] = []
67
+ for raw in raw_servers:
68
+ conf = dict(_field(raw, "conf") or _field(raw, "configuration") or {})
69
+ tools = conf.get("__mcp_tools")
70
+ servers.append(
71
+ McpServer(
72
+ name=str(_field(raw, "name")),
73
+ url=conf.get("__mcp_url") or f"http://{_field(raw, 'address')}:{_field(raw, 'port')}/mcp",
74
+ headers=dict(conf.get("__mcp_headers") or {}),
75
+ tools=tuple(tools) if tools else None,
76
+ namespace=bool(conf.get("__mcp_namespace") or conf.get("__mcp_headers")),
77
+ )
78
+ )
79
+ return servers
80
+
81
+
82
+ def _offered_name(server: McpServer, tool: str) -> str:
83
+ if not server.namespace:
84
+ return tool
85
+ return _UNSUPPORTED.sub("_", f"{server.name}__{tool}")[:64]
86
+
87
+
88
+ class _Session:
89
+ """A minimal Streamable HTTP MCP session against one server."""
90
+
91
+ def __init__(self, server: McpServer, client: httpx.AsyncClient) -> None:
92
+ self._server = server
93
+ self._client = client
94
+ self._session_id: str | None = None
95
+ self._next_id = 0
96
+ self._initialized = False
97
+
98
+ async def _post(self, payload: dict[str, Any]) -> dict[str, Any] | None:
99
+ headers = {
100
+ **self._server.headers,
101
+ "Content-Type": "application/json",
102
+ "Accept": "application/json, text/event-stream",
103
+ "MCP-Protocol-Version": PROTOCOL_VERSION,
104
+ }
105
+ if self._session_id:
106
+ headers["Mcp-Session-Id"] = self._session_id
107
+ try:
108
+ response = await self._client.post(self._server.url, json=payload, headers=headers)
109
+ except httpx.HTTPError as exc:
110
+ raise McpError(f"{self._server.name}: unreachable ({type(exc).__name__})") from exc
111
+ self._session_id = response.headers.get("Mcp-Session-Id") or self._session_id
112
+ if response.status_code == 202 or "id" not in payload:
113
+ return None
114
+ if response.status_code >= 400:
115
+ raise McpError(f"{self._server.name}: HTTP {response.status_code}")
116
+ message = self._message(response, payload["id"])
117
+ if message.get("error"):
118
+ raise McpError(f"{self._server.name}: {message['error'].get('message', message['error'])}")
119
+ return message.get("result") or {}
120
+
121
+ @staticmethod
122
+ def _message(response: httpx.Response, id_: int) -> dict[str, Any]:
123
+ if "text/event-stream" not in response.headers.get("Content-Type", ""):
124
+ return response.json()
125
+ messages = []
126
+ for event in re.split(r"\r?\n\r?\n", response.text):
127
+ data = "\n".join(line[5:].strip() for line in event.splitlines() if line.startswith("data:"))
128
+ if data:
129
+ try:
130
+ messages.append(json.loads(data))
131
+ except json.JSONDecodeError:
132
+ continue
133
+ for message in messages:
134
+ if message.get("id") == id_:
135
+ return message
136
+ raise McpError("event stream without an answer")
137
+
138
+ async def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
139
+ if not self._initialized:
140
+ await self._initialize()
141
+ return await self._call(method, params) or {}
142
+
143
+ async def _call(self, method: str, params: dict[str, Any] | None) -> dict[str, Any] | None:
144
+ self._next_id += 1
145
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "id": self._next_id, "method": method}
146
+ if params is not None:
147
+ payload["params"] = params
148
+ return await self._post(payload)
149
+
150
+ async def _initialize(self) -> None:
151
+ await self._call(
152
+ "initialize",
153
+ {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
154
+ "clientInfo": {"name": "sofias-sdk-lite", "version": "1"}},
155
+ )
156
+ await self._post({"jsonrpc": "2.0", "method": "notifications/initialized"})
157
+ self._initialized = True
158
+
159
+
160
+ class McpToolProvider:
161
+ """``ToolProvider`` over the connectors the platform sent for this turn.
162
+
163
+ Connects lazily on the first ``get_tools``. A connector that cannot be
164
+ reached is skipped (and logged) so the others keep working.
165
+ """
166
+
167
+ def __init__(self, raw_servers: list[Any], *, transport: httpx.AsyncBaseTransport | None = None,
168
+ timeout: float = 60.0) -> None:
169
+ self._servers = mcp_servers_from_message(raw_servers)
170
+ self._client = httpx.AsyncClient(transport=transport, timeout=timeout)
171
+ self._sessions = {server.name: _Session(server, self._client) for server in self._servers}
172
+ self._routes: dict[str, tuple[str, str]] = {}
173
+ self._specs: list[ToolSpec] | None = None
174
+
175
+ async def get_tools(self, node_name: str) -> list[ToolSpec]:
176
+ if self._specs is None:
177
+ self._specs = []
178
+ for server in self._servers:
179
+ try:
180
+ listed = await self._list(server)
181
+ except McpError as exc:
182
+ logger.warning("mcp_server_unavailable", server=server.name, error=str(exc))
183
+ continue
184
+ self._specs.extend(listed)
185
+ return list(self._specs)
186
+
187
+ async def _list(self, server: McpServer) -> list[ToolSpec]:
188
+ specs: list[ToolSpec] = []
189
+ cursor: str | None = None
190
+ while True:
191
+ result = await self._sessions[server.name].request("tools/list", {"cursor": cursor} if cursor else None)
192
+ for tool in result.get("tools", []):
193
+ name = tool.get("name")
194
+ if not name or (server.tools is not None and name not in server.tools):
195
+ continue
196
+ offered = _offered_name(server, name)
197
+ self._routes[offered] = (server.name, name)
198
+ specs.append(ToolSpec(name=offered, description=tool.get("description") or name,
199
+ parameters_schema=tool.get("inputSchema") or {"type": "object"}))
200
+ cursor = result.get("nextCursor")
201
+ if not cursor:
202
+ return specs
203
+
204
+ async def execute_tool(self, tool_name: str, tool_input: dict[str, Any]) -> Any:
205
+ if self._specs is None:
206
+ await self.get_tools("")
207
+ route = self._routes.get(tool_name)
208
+ if route is None:
209
+ raise McpError(f"unknown MCP tool '{tool_name}'")
210
+ server_name, real_name = route
211
+ result = await self._sessions[server_name].request(
212
+ "tools/call", {"name": real_name, "arguments": tool_input or {}}
213
+ )
214
+ if result.get("isError"):
215
+ raise McpError(_text(result) or f"{tool_name} failed")
216
+ return _with_structured(result)
217
+
218
+ async def aclose(self) -> None:
219
+ await self._client.aclose()
220
+
221
+
222
+ def _text(result: dict[str, Any]) -> str:
223
+ return "\n".join(block.get("text", "") for block in result.get("content", []) if block.get("type") == "text")
224
+
225
+
226
+ def _with_structured(result: dict[str, Any]) -> str:
227
+ """The text of a tool result plus its ``structuredContent``, unless the text
228
+ already is that same JSON. Some servers only put a summary in the text."""
229
+ text = _text(result)
230
+ structured = result.get("structuredContent")
231
+ if not structured:
232
+ return text if result.get("content") else json.dumps(result)
233
+ for block in result.get("content", []):
234
+ try:
235
+ if json.loads(block.get("text", "")) == structured:
236
+ return text
237
+ except (TypeError, ValueError):
238
+ continue
239
+ serialized = json.dumps(structured)
240
+ return f"{text}\n{serialized}" if text else serialized
@@ -37,6 +37,7 @@ from pydantic import BaseModel, Field, field_validator, model_validator
37
37
  import nh3
38
38
 
39
39
  from sofias_sdk_lite.contracts.strict_contract import StrictContract
40
+ from sofias_sdk_lite.files import Attachment, FileAccess
40
41
  from sofias_sdk_lite.observability._log import get_logger
41
42
 
42
43
  logger = get_logger("messaging.models")
@@ -105,6 +106,15 @@ class _BaseTaskMessage(BaseModel):
105
106
  available_agents: dict[str, Any] | list[str] = {}
106
107
  agent_internal_capabilities: str | None = None
107
108
 
109
+ # --- Files (0.4.0) ---
110
+ # What the user attached: download URLs, and their details. For third-party
111
+ # agents both point at the platform's intermediary, with the turn's grant.
112
+ attachments: list[str] = []
113
+ attachment_details: list[Attachment] = []
114
+ # Where to download from and upload to with the turn's grant (``None`` when
115
+ # the platform sends no intermediary, e.g. a local run). See ``sofias_sdk_lite.files``.
116
+ files: FileAccess | None = None
117
+
108
118
  # --- Delegation (optional) ---
109
119
  reply_to: str | None = None
110
120
  correlation_id: str | None = None
@@ -48,6 +48,7 @@ from sofias_sdk_lite.llm import (
48
48
  )
49
49
  from sofias_sdk_lite.nodes.base_node import BaseNode, NodeType
50
50
  from sofias_sdk_lite.nodes.prompt_assembler import PromptAssembler
51
+ from sofias_sdk_lite.observability.tool_events import emit_tool_call
51
52
  from sofias_sdk_lite.observability._log import get_logger
52
53
 
53
54
  if TYPE_CHECKING:
@@ -727,7 +728,11 @@ class LLMNode(BaseNode):
727
728
  )
728
729
 
729
730
  async def _execute_tool(self, tool_call: ToolCall) -> ToolResult:
730
- """Execute a single tool call.
731
+ """Execute a single tool call and leave its event line (``emit_tool_call``).
732
+
733
+ Both paths -- streaming and not -- and both kinds of tool -- your own
734
+ and the connectors' -- come through here, so this is the one place the
735
+ sandbox logs learn which tools ran.
731
736
 
732
737
  Args:
733
738
  tool_call: The tool call to execute.
@@ -735,6 +740,20 @@ class LLMNode(BaseNode):
735
740
  Returns:
736
741
  ToolResult with success/failure and output/error.
737
742
  """
743
+ started = time.perf_counter()
744
+ result = await self._run_tool(tool_call)
745
+ known = tool_call.name in self._tools_by_name or self._tool_registry is not None
746
+ emit_tool_call(
747
+ tool_call.name,
748
+ ok=result.success,
749
+ duration_ms=(time.perf_counter() - started) * 1000,
750
+ node_name=self.name,
751
+ error_type=None if result.success else ("failed" if known else "not_found"),
752
+ )
753
+ return result
754
+
755
+ async def _run_tool(self, tool_call: ToolCall) -> ToolResult:
756
+ """Execute a single tool call (see ``_execute_tool``)."""
738
757
  tool_start = time.perf_counter()
739
758
  tool = self._tools_by_name.get(tool_call.name)
740
759
  if tool is None:
@@ -0,0 +1,80 @@
1
+ """One line per tool call, for the developer portal's sandbox logs.
2
+
3
+ Every tool the model calls -- your own or a connector's -- leaves one line on
4
+ standard output, whatever your logging configuration::
5
+
6
+ SOFIAS_EVENT {"type":"tool_call","tool":"search","server":"notion","ok":true,"duration_ms":412,...}
7
+
8
+ The portal reads these lines to show which tools your agent called. They carry
9
+ names, timings and outcome only: never the arguments or the results, which may
10
+ hold your users' data. Set ``SOFIAS_EVENTS=off`` to silence them.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import os
18
+ import sys
19
+ import time
20
+ from typing import Any
21
+
22
+ __all__ = ["EVENT_PREFIX", "emit_tool_call"]
23
+
24
+ #: Marks the lines the portal parses; everything else in the log is shown raw.
25
+ EVENT_PREFIX = "SOFIAS_EVENT"
26
+
27
+ _logger = logging.getLogger("sofias_sdk_lite.events")
28
+
29
+
30
+ class _StdoutHandler(logging.StreamHandler): # type: ignore[type-arg]
31
+ """Writes to whatever ``sys.stdout`` is at emit time (it may be swapped later)."""
32
+
33
+ @property # type: ignore[override]
34
+ def stream(self) -> Any:
35
+ return sys.stdout
36
+
37
+ @stream.setter
38
+ def stream(self, _value: Any) -> None:
39
+ pass
40
+
41
+
42
+ def _ensure_handler() -> None:
43
+ """Own stdout handler, so the line appears even without logging configured."""
44
+ if _logger.handlers:
45
+ return
46
+ handler = _StdoutHandler()
47
+ handler.setFormatter(logging.Formatter("%(message)s"))
48
+ _logger.addHandler(handler)
49
+ _logger.setLevel(logging.INFO)
50
+ _logger.propagate = False
51
+
52
+
53
+ def emit_tool_call(
54
+ tool_name: str,
55
+ *,
56
+ ok: bool,
57
+ duration_ms: float,
58
+ node_name: str | None = None,
59
+ error_type: str | None = None,
60
+ ) -> None:
61
+ """Write the event line for one tool call (no arguments, no results)."""
62
+ if os.environ.get("SOFIAS_EVENTS", "").lower() in {"off", "0", "false"}:
63
+ return
64
+ server, separator, tool = tool_name.partition("__")
65
+ if not separator:
66
+ server, tool = "", tool_name
67
+ event: dict[str, Any] = {
68
+ "type": "tool_call",
69
+ "tool": tool,
70
+ "server": server or None,
71
+ "ok": ok,
72
+ "duration_ms": round(duration_ms),
73
+ "node": node_name,
74
+ "error_type": error_type,
75
+ "ts": round(time.time(), 3),
76
+ }
77
+ _ensure_handler()
78
+ _logger.info(
79
+ "%s %s", EVENT_PREFIX, json.dumps(event, separators=(",", ":"), ensure_ascii=False)
80
+ )
File without changes