sofias-sdk-lite 0.3.0__tar.gz → 0.4.1__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.3.0 → sofias_sdk_lite-0.4.1}/PKG-INFO +1 -1
  2. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/pyproject.toml +4 -1
  3. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/pyproject.toml.orig +8 -1
  4. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/__init__.py +11 -0
  5. sofias_sdk_lite-0.4.1/src/sofias_sdk_lite/files.py +195 -0
  6. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/messaging/models.py +10 -0
  7. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/llm_node.py +63 -9
  8. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/prompt_assembler.py +65 -1
  9. sofias_sdk_lite-0.4.1/src/sofias_sdk_lite/observability/tool_events.py +80 -0
  10. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/LICENSE +0 -0
  11. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/README.md +0 -0
  12. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/agent/__init__.py +0 -0
  13. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/agent/agent.py +0 -0
  14. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/agent/agent_builder.py +0 -0
  15. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/agent/agent_config.py +0 -0
  16. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/agent/execution_context.py +0 -0
  17. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/agent/middleware.py +0 -0
  18. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/agent/response_workflow.py +0 -0
  19. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/agent/streaming.py +0 -0
  20. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/config/__init__.py +0 -0
  21. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/config/agent_settings.py +0 -0
  22. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/config/defaults.py +0 -0
  23. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/config/runtime_config.py +0 -0
  24. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/config/sdk_config.py +0 -0
  25. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/config/source.py +0 -0
  26. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/contracts/__init__.py +0 -0
  27. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/contracts/agent_contracts.py +0 -0
  28. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/contracts/base_contracts.py +0 -0
  29. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/contracts/node_contracts.py +0 -0
  30. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/contracts/strict_contract.py +0 -0
  31. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/errors/__init__.py +0 -0
  32. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/errors/circuit_breaker.py +0 -0
  33. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/errors/error_handler.py +0 -0
  34. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/errors/exceptions.py +0 -0
  35. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/llm/__init__.py +0 -0
  36. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/llm/factory.py +0 -0
  37. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/llm/gateway.py +0 -0
  38. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/llm/openai_compatible.py +0 -0
  39. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/llm/protocol.py +0 -0
  40. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/llm/tokens.py +0 -0
  41. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/llm/tools.py +0 -0
  42. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/mcp.py +0 -0
  43. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/messaging/__init__.py +0 -0
  44. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/__init__.py +0 -0
  45. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/aggregator_config.py +0 -0
  46. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/aggregator_node.py +0 -0
  47. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/base_node.py +0 -0
  48. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/delegation_config.py +0 -0
  49. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/delegation_node.py +0 -0
  50. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/delegation_transport.py +0 -0
  51. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/function_node.py +0 -0
  52. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/llm_node_config.py +0 -0
  53. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/plan_executor.py +0 -0
  54. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/planner_node.py +0 -0
  55. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/nodes/planning_models.py +0 -0
  56. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/observability/__init__.py +0 -0
  57. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/observability/_log.py +0 -0
  58. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/observability/events.py +0 -0
  59. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/observability/trace_context.py +0 -0
  60. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/observability/tracing.py +0 -0
  61. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/py.typed +0 -0
  62. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/rabbitmq/__init__.py +0 -0
  63. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/rabbitmq/client.py +0 -0
  64. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/rabbitmq/config.py +0 -0
  65. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/rabbitmq/consumer.py +0 -0
  66. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/rabbitmq/delegation_transport.py +0 -0
  67. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/rabbitmq/publisher.py +0 -0
  68. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/rabbitmq/rpc_client.py +0 -0
  69. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/rabbitmq/types.py +0 -0
  70. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/routing/__init__.py +0 -0
  71. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/routing/router.py +0 -0
  72. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/routing/strategies.py +0 -0
  73. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/runner/__init__.py +0 -0
  74. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/runner/config.py +0 -0
  75. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/runner/runner.py +0 -0
  76. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/state/__init__.py +0 -0
  77. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/state/conversation_state.py +0 -0
  78. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/state/history.py +0 -0
  79. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/state/memory.py +0 -0
  80. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/tools.py +0 -0
  81. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/workflows/__init__.py +0 -0
  82. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/workflows/chat.py +0 -0
  83. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/src/sofias_sdk_lite/workflows/null.py +0 -0
  84. {sofias_sdk_lite-0.3.0 → sofias_sdk_lite-0.4.1}/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.3.0
3
+ Version: 0.4.1
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sofias-sdk-lite"
3
- version = "0.3.0"
3
+ version = "0.4.1"
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"
@@ -68,6 +68,9 @@ markers = ["integration: requires a running RabbitMQ broker"]
68
68
  line-length = 100
69
69
  target-version = "py311"
70
70
 
71
+ [tool.ruff.lint]
72
+ extend-select = ["TC004"]
73
+
71
74
  [tool.pyright]
72
75
  include = ["src"]
73
76
  typeCheckingMode = "strict"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sofias-sdk-lite"
3
- version = "0.3.0"
3
+ version = "0.4.1"
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"
@@ -75,6 +75,13 @@ markers = ["integration: requires a running RabbitMQ broker"]
75
75
  line-length = 100
76
76
  target-version = "py311"
77
77
 
78
+ [tool.ruff.lint]
79
+ # TC004 catches a name imported only under ``if TYPE_CHECKING:`` but used at
80
+ # runtime. That shipped in 0.4.0 as a NameError the moment a node executed a
81
+ # caller-owned tool (TKT-067). Neither pyright strict nor mypy flag it: to them
82
+ # the symbol exists. Ruff is the one checker that does.
83
+ extend-select = ["TC004"]
84
+
78
85
  [tool.pyright]
79
86
  include = ["src"]
80
87
  typeCheckingMode = "strict"
@@ -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}"}}
@@ -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:
@@ -599,6 +600,7 @@ class LLMNode(BaseNode):
599
600
  base_prompt: str,
600
601
  prompt: str,
601
602
  runtime_vars: dict[str, Any],
603
+ input_data: dict[str, Any],
602
604
  ) -> tuple[int, list[ToolSpec] | None, str]:
603
605
  """Re-read the tools when the catalog changed mid-turn.
604
606
 
@@ -615,6 +617,7 @@ class LLMNode(BaseNode):
615
617
  would duplicate the schemas inside the text.
616
618
  prompt: Current prompt.
617
619
  runtime_vars: Template variables for this turn.
620
+ input_data: The node's validated input for this turn.
618
621
 
619
622
  Returns:
620
623
  Updated ``(revision, specs, prompt)``, or the ones received when
@@ -634,14 +637,33 @@ class LLMNode(BaseNode):
634
637
  return (
635
638
  revision,
636
639
  new_specs,
637
- self._prompt_with_registry_tools(base_prompt, new_specs, runtime_vars),
640
+ self._prompt_with_registry_tools(base_prompt, new_specs, runtime_vars, input_data),
638
641
  )
639
642
 
643
+ def _assemble_prompt(self, runtime_vars: dict[str, Any], input_data: dict[str, Any]) -> str:
644
+ """Assemble this turn's prompt with the node's assembler.
645
+
646
+ ``input_data`` only reaches an assembler we built ourselves. A
647
+ caller-supplied one was written against ``assemble(runtime_vars=...)``
648
+ and would reject the extra keyword, so it keeps the signature it knows.
649
+
650
+ Args:
651
+ runtime_vars: Template variables for this turn (settings + input).
652
+ input_data: The node's validated input on its own.
653
+
654
+ Returns:
655
+ The assembled prompt.
656
+ """
657
+ if not self._owns_prompt_assembler:
658
+ return self._prompt_assembler.assemble(runtime_vars=runtime_vars)
659
+ return self._prompt_assembler.assemble(runtime_vars=runtime_vars, input_data=input_data)
660
+
640
661
  def _prompt_with_registry_tools(
641
662
  self,
642
663
  prompt: str,
643
664
  tool_specs: list[ToolSpec] | None,
644
665
  runtime_vars: dict[str, Any],
666
+ input_data: dict[str, Any],
645
667
  ) -> str:
646
668
  """Re-assemble the prompt including the registry's tools.
647
669
 
@@ -655,6 +677,7 @@ class LLMNode(BaseNode):
655
677
  prompt: Prompt already assembled with the node's assembler.
656
678
  tool_specs: Static plus registry tools, or None.
657
679
  runtime_vars: Template variables for this turn.
680
+ input_data: The node's validated input for this turn.
658
681
 
659
682
  Returns:
660
683
  The re-assembled prompt, or the one received when no rebuild applies.
@@ -670,7 +693,7 @@ class LLMNode(BaseNode):
670
693
  output_contract=self._config.output_contract,
671
694
  raw_text=self._raw_text_field is not None,
672
695
  )
673
- return assembler.assemble(runtime_vars=runtime_vars)
696
+ return assembler.assemble(runtime_vars=runtime_vars, input_data=input_data)
674
697
 
675
698
  async def _execute_registry_tool(self, tool_call: ToolCall, tool_start: float) -> ToolResult:
676
699
  """Execute a tool call via the ToolRegistry.
@@ -727,7 +750,11 @@ class LLMNode(BaseNode):
727
750
  )
728
751
 
729
752
  async def _execute_tool(self, tool_call: ToolCall) -> ToolResult:
730
- """Execute a single tool call.
753
+ """Execute a single tool call and leave its event line (``emit_tool_call``).
754
+
755
+ Both paths -- streaming and not -- and both kinds of tool -- your own
756
+ and the connectors' -- come through here, so this is the one place the
757
+ sandbox logs learn which tools ran.
731
758
 
732
759
  Args:
733
760
  tool_call: The tool call to execute.
@@ -735,6 +762,20 @@ class LLMNode(BaseNode):
735
762
  Returns:
736
763
  ToolResult with success/failure and output/error.
737
764
  """
765
+ started = time.perf_counter()
766
+ result = await self._run_tool(tool_call)
767
+ known = tool_call.name in self._tools_by_name or self._tool_registry is not None
768
+ emit_tool_call(
769
+ tool_call.name,
770
+ ok=result.success,
771
+ duration_ms=(time.perf_counter() - started) * 1000,
772
+ node_name=self.name,
773
+ error_type=None if result.success else ("failed" if known else "not_found"),
774
+ )
775
+ return result
776
+
777
+ async def _run_tool(self, tool_call: ToolCall) -> ToolResult:
778
+ """Execute a single tool call (see ``_execute_tool``)."""
738
779
  tool_start = time.perf_counter()
739
780
  tool = self._tools_by_name.get(tool_call.name)
740
781
  if tool is None:
@@ -760,6 +801,12 @@ class LLMNode(BaseNode):
760
801
  if modified_input is not None:
761
802
  tool_input = modified_input
762
803
 
804
+ # ToolContext is a TYPE_CHECKING-only name in this module, and this is
805
+ # the one place that BUILDS one instead of just annotating with it, so
806
+ # it needs the real import at runtime. Same local-import idiom already
807
+ # used for DelegationNode, FanOutStrategy and StreamingResponseWorkflow.
808
+ from sofias_sdk_lite.tools import ToolContext
809
+
763
810
  # Execute the tool using BaseTool.execute() in a worker thread (async, non-blocking)
764
811
  try:
765
812
  context = ToolContext(
@@ -980,7 +1027,7 @@ class LLMNode(BaseNode):
980
1027
 
981
1028
  runtime_vars = self._get_runtime_vars()
982
1029
  runtime_vars.update(input_data)
983
- prompt = self._prompt_assembler.assemble(runtime_vars=runtime_vars)
1030
+ prompt = self._assemble_prompt(runtime_vars, input_data)
984
1031
 
985
1032
  max_iterations = self._get_max_iterations()
986
1033
  parallel_tool_calls = self._get_parallel_tool_calls()
@@ -988,7 +1035,7 @@ class LLMNode(BaseNode):
988
1035
  tool_catalog_revision = self._tool_catalog_revision()
989
1036
 
990
1037
  base_prompt = prompt
991
- prompt = self._prompt_with_registry_tools(prompt, tool_specs, runtime_vars)
1038
+ prompt = self._prompt_with_registry_tools(prompt, tool_specs, runtime_vars, input_data)
992
1039
 
993
1040
  history: list[tuple[LLMResponse, list[ToolResult]]] = []
994
1041
  iteration = 0
@@ -1000,7 +1047,7 @@ class LLMNode(BaseNode):
1000
1047
 
1001
1048
  while iteration < max_iterations:
1002
1049
  tool_catalog_revision, tool_specs, prompt = await self._refresh_tool_catalog(
1003
- tool_catalog_revision, tool_specs, base_prompt, prompt, runtime_vars
1050
+ tool_catalog_revision, tool_specs, base_prompt, prompt, runtime_vars, input_data
1004
1051
  )
1005
1052
 
1006
1053
  if token_budget and history:
@@ -1474,7 +1521,7 @@ class LLMNode(BaseNode):
1474
1521
  runtime_vars = self._get_runtime_vars()
1475
1522
  # Include validated input in runtime vars for prompt templates
1476
1523
  runtime_vars.update(input_data)
1477
- prompt = self._prompt_assembler.assemble(runtime_vars=runtime_vars)
1524
+ prompt = self._assemble_prompt(runtime_vars, input_data)
1478
1525
 
1479
1526
  # Tool loop
1480
1527
  max_iterations = self._get_max_iterations()
@@ -1483,7 +1530,9 @@ class LLMNode(BaseNode):
1483
1530
  tool_catalog_revision = self._tool_catalog_revision()
1484
1531
 
1485
1532
  base_prompt = prompt
1486
- prompt = self._prompt_with_registry_tools(prompt, tool_specs, runtime_vars)
1533
+ prompt = self._prompt_with_registry_tools(
1534
+ prompt, tool_specs, runtime_vars, input_data
1535
+ )
1487
1536
 
1488
1537
  history: list[tuple[LLMResponse, list[ToolResult]]] = []
1489
1538
  iteration = 0
@@ -1506,7 +1555,12 @@ class LLMNode(BaseNode):
1506
1555
 
1507
1556
  while iteration < max_iterations:
1508
1557
  tool_catalog_revision, tool_specs, prompt = await self._refresh_tool_catalog(
1509
- tool_catalog_revision, tool_specs, base_prompt, prompt, runtime_vars
1558
+ tool_catalog_revision,
1559
+ tool_specs,
1560
+ base_prompt,
1561
+ prompt,
1562
+ runtime_vars,
1563
+ input_data,
1510
1564
  )
1511
1565
 
1512
1566
  if token_budget and history:
@@ -13,6 +13,7 @@ from pathlib import Path
13
13
  from typing import TYPE_CHECKING, Any
14
14
 
15
15
  from sofias_sdk_lite.llm import ToolSpec
16
+ from sofias_sdk_lite.observability._log import get_logger
16
17
 
17
18
  if TYPE_CHECKING:
18
19
  from sofias_sdk_lite.contracts import StrictContract
@@ -21,6 +22,8 @@ if TYPE_CHECKING:
21
22
  # Re-export so existing importers keep working
22
23
  __all__ = ["PromptAssembler", "ToolSpec"]
23
24
 
25
+ logger = get_logger("nodes.prompt_assembler")
26
+
24
27
 
25
28
  class PromptAssembler:
26
29
  """Assembles the final prompt for LLM calls.
@@ -230,7 +233,44 @@ class PromptAssembler:
230
233
  """
231
234
  return ""
232
235
 
233
- def assemble(self, runtime_vars: dict[str, Any] | None = None) -> str:
236
+ @staticmethod
237
+ def _keys_used_by_template(template: str, input_data: dict[str, Any]) -> list[str]:
238
+ """Which input keys the template actually references as {placeholders}.
239
+
240
+ Args:
241
+ template: The fixed prompt, before variable resolution.
242
+ input_data: The node's validated input.
243
+
244
+ Returns:
245
+ The referenced keys, empty when the template ignores the input.
246
+ """
247
+ return [key for key in input_data if "{" + key + "}" in template]
248
+
249
+ def _format_input_section(self, input_data: dict[str, Any]) -> str:
250
+ """Format the node's input as its own section.
251
+
252
+ The input reaches the model ONLY through {placeholders} in the fixed
253
+ prompt: there is no separate "user message" channel. A node configured
254
+ without ``NodePromptConfig`` therefore sent the model the tool catalogue
255
+ and nothing else, dropping the user's message in silence (TKT-066).
256
+
257
+ Args:
258
+ input_data: The node's validated input.
259
+
260
+ Returns:
261
+ Formatted input section, or empty string when there is no input.
262
+ """
263
+ if not input_data:
264
+ return ""
265
+
266
+ body = json.dumps(input_data, indent=2, ensure_ascii=False, default=str)
267
+ return f"## Input\n\n```json\n{body}\n```"
268
+
269
+ def assemble(
270
+ self,
271
+ runtime_vars: dict[str, Any] | None = None,
272
+ input_data: dict[str, Any] | None = None,
273
+ ) -> str:
234
274
  """Assemble the final prompt.
235
275
 
236
276
  Combines the fixed prompt with variable sections (tools, output format)
@@ -238,6 +278,11 @@ class PromptAssembler:
238
278
 
239
279
  Args:
240
280
  runtime_vars: Optional runtime variables for template resolution.
281
+ input_data: The node's validated input, when the caller can tell it
282
+ apart from ``runtime_vars`` (which also carries agent settings).
283
+ Used to render the input for a node that has no prompt template,
284
+ and to warn when a template ignores it. Optional, so a
285
+ caller-supplied assembler keeps working without it.
241
286
 
242
287
  Returns:
243
288
  The complete assembled prompt ready for LLM invocation.
@@ -264,5 +309,24 @@ class PromptAssembler:
264
309
  if output_section:
265
310
  sections.append(output_section)
266
311
 
312
+ if input_data:
313
+ if not fixed_prompt:
314
+ # No template at all: without this the input never reaches the
315
+ # model. Appended last, after the tool schemas, so the task sits
316
+ # closest to where the model answers.
317
+ input_section = self._format_input_section(input_data)
318
+ if input_section:
319
+ sections.append(input_section)
320
+ elif not self._keys_used_by_template(fixed_prompt, input_data):
321
+ # A template that names none of the input keys drops the input
322
+ # just as silently, but it may be deliberate (a node whose input
323
+ # only passes through to the output), so this only warns.
324
+ logger.warning(
325
+ "Prompt template references none of the node's input: the "
326
+ "model will not see it. Reference it as {placeholder}.",
327
+ node_name=self._config.name,
328
+ input_keys=sorted(input_data),
329
+ )
330
+
267
331
  # Join sections with clear separation
268
332
  return "\n\n".join(sections).strip()
@@ -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