swisper-agent-sdk 0.1.0a1__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 (46) hide show
  1. swisper_agent_sdk-0.1.0a1/.gitignore +8 -0
  2. swisper_agent_sdk-0.1.0a1/PKG-INFO +315 -0
  3. swisper_agent_sdk-0.1.0a1/PROMPTING.md +211 -0
  4. swisper_agent_sdk-0.1.0a1/README.md +285 -0
  5. swisper_agent_sdk-0.1.0a1/pyproject.toml +117 -0
  6. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/__init__.py +117 -0
  7. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/agent.py +278 -0
  8. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/auth.py +224 -0
  9. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/card_builder.py +657 -0
  10. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/contract.py +324 -0
  11. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/correlation.py +219 -0
  12. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/data_types/__init__.py +32 -0
  13. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/data_types/calendar_event.py +16 -0
  14. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/data_types/contact.py +15 -0
  15. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/data_types/document.py +16 -0
  16. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/data_types/email_draft.py +16 -0
  17. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/data_types/generic_data.py +18 -0
  18. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/data_types/location.py +16 -0
  19. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/data_types/search_result.py +15 -0
  20. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/data_types/smart_home_state.py +16 -0
  21. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/data_types/task_item.py +15 -0
  22. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/data_utils.py +658 -0
  23. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/delegation.py +228 -0
  24. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/domain_agent_interface.py +196 -0
  25. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/errors.py +25 -0
  26. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/events.py +277 -0
  27. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/examples/__init__.py +18 -0
  28. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/examples/bring_your_own_model_agent.py +86 -0
  29. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/examples/meal_planning_agent.py +202 -0
  30. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/examples/twelve_line_agent.py +31 -0
  31. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/harness.py +225 -0
  32. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/memory.py +102 -0
  33. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/metrics.py +75 -0
  34. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/nodes.py +172 -0
  35. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/prompt_policy.py +122 -0
  36. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/prompts/__init__.py +1 -0
  37. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/prompts/composer.py +122 -0
  38. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/prompts/scaffolding.py +247 -0
  39. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/result_contract/__init__.py +44 -0
  40. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/result_contract/adapter.py +96 -0
  41. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/result_contract/registry.py +68 -0
  42. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/result_contract/types.py +122 -0
  43. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/state_keys.py +38 -0
  44. swisper_agent_sdk-0.1.0a1/src/swisper_agent_sdk/state_types.py +109 -0
  45. swisper_agent_sdk-0.1.0a1/tests/test_declared_dependencies.py +146 -0
  46. swisper_agent_sdk-0.1.0a1/tests/test_install_smoke.py +122 -0
@@ -0,0 +1,8 @@
1
+ __pycache__
2
+ app.egg-info
3
+ *.pyc
4
+ .mypy_cache
5
+ .coverage
6
+ htmlcov
7
+ .cache
8
+ .venv
@@ -0,0 +1,315 @@
1
+ Metadata-Version: 2.5
2
+ Name: swisper-agent-sdk
3
+ Version: 0.1.0a1
4
+ Summary: The published contract a partner builds a Swisper domain agent against
5
+ Project-URL: Homepage, https://github.com/fintama/helvetiq
6
+ Project-URL: Repository, https://github.com/fintama/helvetiq/tree/main/apps/backend/packages/swisper-agent-sdk
7
+ License: MIT
8
+ Keywords: a2a,agent,domain-agent,sdk,swisper
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: jsonpath-ng>=1.7
20
+ Requires-Dist: langchain-core>=0.1
21
+ Requires-Dist: langgraph>=0.2
22
+ Requires-Dist: pydantic>=2.0
23
+ Requires-Dist: pyyaml>=6.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy>=1.8.0; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.2.2; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # swisper-agent-sdk
32
+
33
+ Build a Swisper domain agent.
34
+
35
+ This package is self-contained. You do not need the Swisper backend, a database,
36
+ or network access to build an agent and test it.
37
+
38
+ ## Install
39
+
40
+ Requires **Python 3.11 or newer**.
41
+
42
+ ```bash
43
+ python3 -m venv .venv
44
+ ./.venv/bin/pip install swisper-agent-sdk
45
+ ```
46
+
47
+ Use the virtual environment's interpreter for everything below. Typing `python3`
48
+ rather than `./.venv/bin/python` is the most common way to get a
49
+ `ModuleNotFoundError` that looks like a broken package and is not.
50
+
51
+ > **Five declared dependencies pull in more than five packages.** This package
52
+ > declares `pydantic`, `langgraph`, `jsonpath-ng`, `pyyaml` and `langchain-core` —
53
+ > `langgraph` alone brings its own dependency tree with it (`langsmith`, `httpx`,
54
+ > `orjson`, `ormsgpack`, `zstandard` and more). Measured in a clean install: **36
55
+ > packages**, not five. None of that is a defect — every one of them is something
56
+ > `langgraph` genuinely needs — but a partner watching 36 packages scroll by after
57
+ > being told about five deserves to have been told first.
58
+
59
+ ### From a directory, instead
60
+
61
+ Handed the package as a tarball or a source checkout rather than installing from
62
+ PyPI? Point `pip` at the directory instead of the package name — everything else
63
+ on this page works exactly the same either way:
64
+
65
+ ```bash
66
+ python3 -m venv .venv
67
+ ./.venv/bin/pip install -e ./swisper-agent-sdk
68
+ ```
69
+
70
+ ## Your first agent
71
+
72
+ This is `swisper_agent_sdk/examples/twelve_line_agent.py`, complete and
73
+ unabridged. It ships inside the package itself, so installing it is the only step
74
+ — there is nothing separate to fetch:
75
+
76
+ ```python
77
+ from swisper_agent_sdk import Agent, Delegation, Reply
78
+
79
+ agent = Agent(
80
+ name="echo_agent",
81
+ description="Repeats the task back. The smallest thing that is still an agent.",
82
+ )
83
+
84
+
85
+ @agent.handler
86
+ async def handle(d: Delegation) -> Reply:
87
+ if not d.task:
88
+ return Reply.ask("What would you like me to do?")
89
+ return Reply(text=f"You asked me to: {d.task}")
90
+ ```
91
+
92
+ Run it — the same command whether you installed from PyPI or from a directory:
93
+
94
+ ```console
95
+ $ ./.venv/bin/python -m swisper_agent_sdk.examples.twelve_line_agent
96
+ You asked me to: book a train to Bern
97
+ ```
98
+
99
+ `d.task` is what the user asked for. Returning a `Reply` answers them;
100
+ returning `Reply.ask(...)` puts a question to them and pauses the turn.
101
+
102
+ ## Running a turn yourself
103
+
104
+ `harness` runs a real turn against your agent with **no core, no database and
105
+ no network**:
106
+
107
+ ```python
108
+ import asyncio
109
+ from swisper_agent_sdk import harness
110
+ from my_agent import agent
111
+
112
+ result = asyncio.run(harness.turn(agent, "book a train to Bern"))
113
+ print(result.text)
114
+ ```
115
+
116
+ ### Check your install
117
+
118
+ Needs the directory install above — `tests/` ships in a source checkout, not in
119
+ the plain PyPI wheel:
120
+
121
+ ```console
122
+ $ cd swisper-agent-sdk
123
+ $ ../.venv/bin/pip install -e ".[dev]"
124
+ $ ../.venv/bin/python -m pytest
125
+ ```
126
+
127
+ That runs a handful of smoke tests: the package imports, importing it leaves your
128
+ logging alone, an agent answers a turn offline, and every bundled example runs and
129
+ prints something. If those pass, the SDK works on your machine.
130
+
131
+ > **Run `pytest` from inside this directory.** The package's own
132
+ > `asyncio_mode = "auto"` setting lives in its `pyproject.toml`, and pytest only
133
+ > picks it up when invoked from here. Run it from the parent directory and every
134
+ > `async def` test fails — **including correct ones** — which looks like a broken
135
+ > SDK and is not.
136
+
137
+ `[dev]` is what installs `pytest` and `pytest-asyncio`. The base install
138
+ deliberately does not: it is the runtime, not the toolchain.
139
+
140
+ ### Writing your own tests
141
+
142
+ `turn()` returns a `TurnResult` with `.text`, `.status`, `.resume_with`, `.cards`,
143
+ `.said`, and two helpers for assertions — `.said_something_about(...)` and
144
+ `.asked_for(...)`:
145
+
146
+ ```python
147
+ async def test_it_answers():
148
+ r = await harness.turn(agent, "weather in Bern tomorrow")
149
+ assert r.said_something_about("Bern")
150
+
151
+
152
+ async def test_it_can_ask_a_question():
153
+ r = await harness.turn(agent, "book me a train")
154
+ assert r.asked_for("departure time")
155
+ r = await harness.answer(r, "09:00") # resumes the paused turn
156
+ ```
157
+
158
+ `harness.answer(...)` resumes a turn your agent paused with `Reply.ask(...)`.
159
+ Inside the handler, `d.answer` is what the user replied and `d.resumed` is
160
+ `True`.
161
+
162
+ > **What a green harness run does and does not prove.** The harness fakes core,
163
+ > the capability endpoint and the token. A green harness run proves your agent's
164
+ > logic, not that the capability works against a live core. Those are different
165
+ > claims and only the first one is tested here.
166
+
167
+ ## Saying things, and sending cards
168
+
169
+ Two ways to put something in front of the user besides your final answer.
170
+
171
+ **`await d.say("…")`** narrates a deterministic moment — typically just before a slow
172
+ call, where waiting for the model to decide to mention it is the wrong behaviour:
173
+
174
+ ```python
175
+ await d.say("Checking which trains actually run at that hour…")
176
+ ```
177
+
178
+ **`await d.card("swi-transport", payload)`** emits a card. **The types your agent may
179
+ emit are declared on the agent**, and an undeclared type raises
180
+ `UndeclaredCardTypeError` — never a silent drop:
181
+
182
+ ```python
183
+ agent = Agent(name="…", description="…", cards=["swi-transport"])
184
+
185
+ await d.card("swi-transport", {"from": "Zurich", "to": "Bern"}) # fine
186
+ await d.card("swi-invented", {...}) # UndeclaredCardTypeError
187
+ ```
188
+
189
+ The declared list is also what the prompt tells the model about, so it cannot invent a
190
+ card type that has no renderer. Both are visible in tests as `result.said` and
191
+ `result.cards`.
192
+
193
+ ## A fuller example
194
+
195
+ `swisper_agent_sdk/examples/meal_planning_agent.py` is the one to read once the
196
+ twelve-line agent makes sense. It is a working agent that recalls what it knows
197
+ about you, asks a question when it does not know enough, resumes on your answer,
198
+ and emits a card:
199
+
200
+ ```console
201
+ $ ./.venv/bin/python -m swisper_agent_sdk.examples.meal_planning_agent
202
+ [turn 1] status=waiting_for_input text="I don't have any dietary restrictions or allergies on file for you — could you tell me what I should plan around?"
203
+ [turn 2] status=complete text="Here's a plan built around: I'm vegetarian and allergic to peanuts.."
204
+ cards emitted: ['meal_plan']
205
+ ```
206
+
207
+ It also shows how to reach data you are entitled to. Declare what you need on the
208
+ agent and read it from the delegation:
209
+
210
+ ```python
211
+ agent = Agent(..., inputs=("fact_lookup_service",))
212
+
213
+ # in the handler:
214
+ facts = await d.fact_lookup_service.get_by_type("ALLERGY")
215
+ ```
216
+
217
+ **You only receive what you declare.** Anything you did not ask for is absent from
218
+ your delegation, not present-and-empty.
219
+
220
+ > 🔴 **`fact_lookup_service` works in-process and is withheld over the wire.** It is a
221
+ > live service object, so it cannot be serialised to a remotely deployed agent — core
222
+ > drops it from the envelope unconditionally. **The example above runs green under the
223
+ > harness and returns nothing once your agent is deployed remotely, with no error.**
224
+ >
225
+ > This is stated here because it is the one place where following our own documentation
226
+ > produces a silent failure. Facts are meant to reach a remote agent **pre-loaded onto
227
+ > the envelope**, declared in the agent's contract — **that path is seamed but not yet
228
+ > wired, so a remote agent currently receives no pre-loaded facts.** If you are building
229
+ > for remote deployment, do not design around `fact_lookup_service` yet; talk to us.
230
+
231
+ ## Two ways to write an agent, one runtime
232
+
233
+ `@agent.handler` is not a simplified mode you outgrow. **A handler is compiled
234
+ into a one-node graph**, so it runs through exactly the same machinery as an
235
+ agent you author as a graph yourself:
236
+
237
+ ```python
238
+ agent = Agent(name="…", description="…", graph=my_compiled_graph)
239
+ ```
240
+
241
+ Start with a handler. Move to `graph=` when you need more than one node. You are
242
+ not choosing a path you have to undo later.
243
+
244
+ ## Telling the model how to behave
245
+
246
+ > 🔴 **No planner ships in this package.** `instructions=`, `routing=`,
247
+ > `narration_style=`, `tool_guidance=`, `prompt=` and `tools=` are stored on the
248
+ > `Agent` and are **not read when your agent runs**. Nothing here calls a model.
249
+ >
250
+ > To use a composed prompt today, call **`agent.compose_prompt(...)` yourself** and
251
+ > drive your own model with it — see
252
+ > [`examples/bring_your_own_model_agent.py`](src/swisper_agent_sdk/examples/bring_your_own_model_agent.py).
253
+ > A handler-mode agent decides everything in Python.
254
+ >
255
+ > **Everything in this section describes what `compose_prompt()` returns, not what
256
+ > the runtime does with it.**
257
+
258
+ Most agents need one thing — `instructions=`:
259
+
260
+ ```python
261
+ agent = Agent(
262
+ name="weather_agent",
263
+ description="Answers weather and forecast questions.",
264
+ instructions="""
265
+ You answer questions about weather and forecasts.
266
+ Prefer official meteorological sources over aggregators.
267
+ Never speculate beyond seven days — say you don't know instead.
268
+ """,
269
+ )
270
+ ```
271
+
272
+ **Your `instructions` reach the model verbatim.** We never rewrite them, and they
273
+ are not a template: braces inside them are literal text, so you cannot
274
+ accidentally interpolate one of our internal variables.
275
+
276
+ The SDK composes the rest of the planner prompt around them — the planning loop,
277
+ how to ask a question and resume, how to narrate, and a context block carrying
278
+ the user's language, locale, timezone, today's date and their temporal context.
279
+
280
+ **That context block is why this wrapper exists.** Omit the language instruction
281
+ and you answer a German user in English. Omit the locale rules and dates come out
282
+ American. Omit temporal context and "tomorrow" is the wrong day. None of these
283
+ fail a test you would think to write, and all of them reach a user.
284
+
285
+ If you want to replace one composed section rather than all of it, pass
286
+ `narration_style=`, `tool_guidance=` or `routing=`. If you want full control,
287
+ pass `prompt=` — and `swisper_agent_sdk.prompts.scaffolding()` is importable, so
288
+ you can still assemble the parts you did not want to write yourself.
289
+
290
+ 📖 **[`PROMPTING.md`](PROMPTING.md)** has the full map of what gets composed, plus
291
+ two rules that are not general prompting advice — they are things we got wrong in
292
+ production, in ways that reached users. Read it before you write your second
293
+ agent.
294
+
295
+ ## What this package deliberately does not give you
296
+
297
+ Said up front, because discovering it by hitting a wall is worse:
298
+
299
+ - **No `swisper.*` imports.** The SDK never reaches into the Swisper backend, and
300
+ a build check enforces that on both `src/` and `examples/`. If something you
301
+ need is missing here, that is our gap to close — tell us rather than working
302
+ around it.
303
+ - **No database access.** Your agent never opens a database connection. Data you
304
+ are entitled to arrives through a capability call that core authorises for that
305
+ turn.
306
+ - **Five declared dependencies**, deliberately: `pydantic`, `langgraph`,
307
+ `jsonpath-ng`, `pyyaml` and `langchain-core`. A test asserts that every import
308
+ is declared and every declaration is imported — not a count — so a new one
309
+ cannot arrive as a side effect of a convenient import, undeclared. (See the
310
+ install-time note above: `langgraph` alone brings a much larger dependency
311
+ tree with it — the count above is what *we* declare, not what actually
312
+ installs.)
313
+ - **The SDK does not configure logging.** Importing it leaves your root logger
314
+ untouched. Call `swisper_agent_sdk.correlation.configure_logging()` yourself if
315
+ you want ours.
@@ -0,0 +1,211 @@
1
+ # Prompting a Swisper agent
2
+
3
+ > 🔴 **Read this first: no planner ships in this package.**
4
+ >
5
+ > `instructions=`, `routing=`, `narration_style=`, `tool_guidance=`, `prompt=` and
6
+ > `tools=` are stored on the `Agent` and are **not read when your agent runs**.
7
+ > Nothing in this package calls a model.
8
+ >
9
+ > **`compose_prompt()` genuinely works** — it returns the assembled prompt with your
10
+ > `instructions` verbatim inside it. But *you* call it, *you* supply the model, and
11
+ > *you* drive the loop. See
12
+ > [`examples/bring_your_own_model_agent.py`](src/swisper_agent_sdk/examples/bring_your_own_model_agent.py).
13
+ >
14
+ > **So read every present-tense sentence below as describing what
15
+ > `compose_prompt()` returns**, not what the runtime does with it. The rules are
16
+ > real and worth following — they are what we learned building agents on this
17
+ > prompt — but the composition is yours to invoke.
18
+
19
+ Two rules we paid to learn, a map of the prompt the SDK builds for you, and one
20
+ thing your tests cannot tell you.
21
+
22
+ These are not general prompting advice — there is plenty of that elsewhere. Each
23
+ rule here is something we got wrong first, in production, in a way that reached
24
+ users.
25
+
26
+ ---
27
+
28
+ ## Rule 1 — "when to call this tool" belongs in the tool, not the prompt
29
+
30
+ Put the criteria for calling a tool **in that tool's own description**. Keep the
31
+ planner prompt for **cross-tool sequencing only** — what has to happen before
32
+ what.
33
+
34
+ ```python
35
+ # Good: the criterion travels with the tool.
36
+ def get_forecast(city: str, days: int = 3) -> dict:
37
+ """Return the weather forecast for a city.
38
+
39
+ Call this when the user asks about future conditions. For what the weather
40
+ is doing right now, call get_current instead — this one is modelled and
41
+ coarse for today.
42
+ """
43
+ ```
44
+
45
+ ```
46
+ # Bad: the same criterion in your `instructions=`.
47
+ Use get_forecast when the user asks about future weather, and get_current
48
+ when they ask about now.
49
+ ```
50
+
51
+ **Why.** The second version is correct on the day you write it and rots on its
52
+ own. When someone changes `get_forecast`, they change the function and its
53
+ docstring — they do not go looking through a prompt for a sentence about it.
54
+ The rule and the thing it describes drift apart silently, and the prompt wins,
55
+ because that is what the model reads.
56
+
57
+ Written in the description, the criterion is changed by the same edit that
58
+ changes the behaviour. That is the whole argument: **put the rule where the next
59
+ person editing the code will already be standing.**
60
+
61
+ The SDK derives your `## Your tools` block from the registered functions'
62
+ signatures and full docstrings, so anything you write there reaches the model.
63
+
64
+ ---
65
+
66
+ ## Rule 2 — write a concept and a test question, never a keyword list
67
+
68
+ When you need the model to classify something, describe the **idea** and give it
69
+ a question it can ask itself. Do not enumerate the words.
70
+
71
+ ```
72
+ # Good: a concept plus a test the model can apply.
73
+ Some facts are sensitive. The test: would the person be embarrassed if this
74
+ appeared on a shared screen without warning? Health, finances, relationships
75
+ and legal matters usually pass that test; a favourite coffee order does not.
76
+ ```
77
+
78
+ ```
79
+ # Bad: a keyword table.
80
+ Treat as sensitive if the message contains: diagnosis, illness, therapy,
81
+ salary, debt, divorce, lawyer, ...
82
+ ```
83
+
84
+ **Why.** Keyword lists fail on the two things that are guaranteed to happen.
85
+
86
+ **They fail on other languages.** Swisper answers in the user's language. A list
87
+ written in English does not fire on *Diagnose*, *Schulden* or *scheiden* — and
88
+ the failure is silent, because a classifier that never fires looks exactly like a
89
+ classifier with nothing to find.
90
+
91
+ **They fail on typos.** `divorce` and `divorec` are the same disclosure to a
92
+ human and different strings to a matcher. Real messages contain typos at a rate
93
+ that makes this routine, not an edge case.
94
+
95
+ A concept generalises to both for free. Keep examples — they are useful — but
96
+ write them as *illustrations of the idea*, never as the set to be matched.
97
+
98
+ ---
99
+
100
+ ## What the SDK composes around your `instructions`
101
+
102
+ At level 1 you write `instructions=` and the SDK assembles this:
103
+
104
+ ```
105
+ [SYSTEM]
106
+ ├─ planner scaffolding the iterative loop, batching
107
+ ├─ HITL rules how to ask, how to resume, how to escape
108
+ ├─ card instructions when to emit; only the types you declared
109
+ ├─ memory guidance when recall is worth calling
110
+ │
111
+ ├─ ## Your domain ← your `instructions`, VERBATIM
112
+ ├─ ## Your tools ← derived from your registered functions
113
+ ├─ ## Context ← language · locale · timezone · date · temporal
114
+ │
115
+ └─ narration instructions ALWAYS LAST — see below
116
+ ```
117
+
118
+ **Your `instructions` are interpolated verbatim and never rewritten.** They are
119
+ not a template: braces inside them are literal, so `{{ user_id }}` in your text
120
+ reaches the model as those characters and cannot pull in one of our variables.
121
+
122
+ **At level 2** you replace one named block and leave the rest composed:
123
+ `narration_style=` replaces the narration instructions, `tool_guidance=` replaces
124
+ the content under `## Your tools`, `routing=` replaces the HITL rules.
125
+
126
+ **At level 3** you pass `prompt=` and own the whole thing. If you want some of
127
+ the scaffolding back, `swisper_agent_sdk.prompts.scaffolding()` is importable and
128
+ returns the blocks individually.
129
+
130
+ ### Narration is appended last, and that is deliberate
131
+
132
+ The narration block is always the final thing in the assembled prompt, at every
133
+ level. **Placement is load-bearing.** A tone instruction sitting thousands of
134
+ characters before the end does not land; the same words at the end do. If you
135
+ override it with `narration_style=`, your text takes that position.
136
+
137
+ ⚠️ **The default narration examples are travel-flavoured** — trains, stations,
138
+ fares. They are there as *mechanism*: positive examples shape the model's output
139
+ far more reliably than instructions about what not to do. If you are building a
140
+ weather or finance agent, the shape still transfers, but consider passing
141
+ `narration_style=` with three examples from your own domain. Keep three, and keep
142
+ them concrete.
143
+
144
+ ---
145
+
146
+ ## What a green test run does not tell you
147
+
148
+ > **The harness fakes core, the capability endpoint and the token. A green
149
+ > harness run proves your agent's logic, not that the capability works against a
150
+ > live core.**
151
+
152
+ Those are different claims and only the first one is tested offline.
153
+
154
+ This is not a defect in the harness — faking the capability endpoint is exactly
155
+ what lets your tests run with no core, no database and no network, which is worth
156
+ far more than the certainty you give up. But it means a passing suite is evidence
157
+ about **your** code, not about the integration.
158
+
159
+ The failure it protects you from: your tests are green, you run against a real
160
+ core, `recall` returns nothing, and you spend an afternoon debugging your own
161
+ agent. It is not your agent. Check the integration separately, and treat the
162
+ first live run as its own test.
163
+
164
+ ---
165
+
166
+ ## `inputs=` is two mechanisms wearing one name
167
+
168
+ You declare what your agent receives with `inputs=(...)`. **What that declaration does
169
+ depends on where your agent runs, and the two behaviours are not the same:**
170
+
171
+ | | in-process | deployed remotely |
172
+ |---|---|---|
173
+ | `inputs=` | an **enforced allowlist** — you get what you declared, and nothing else | **no such concept exists on the wire path** |
174
+ | `fact_lookup_service` | reaches your handler | **withheld unconditionally** — it is a live object and cannot serialise |
175
+
176
+ **The consequence is the one worth remembering: a handler that reads
177
+ `d.fact_lookup_service` passes every offline test and returns nothing in production.**
178
+ The harness runs the in-process path, so it cannot show you this. Nothing raises.
179
+
180
+ **And the contract file cannot tell the two apart.** `agent.contract.yaml` describes an
181
+ agent without knowing where it will run, so a declaration that is enforcement in one
182
+ deployment is decoration in the other.
183
+
184
+ Facts are meant to reach a remote agent **pre-loaded onto the envelope**, with the types
185
+ declared in its contract rather than fetched live. **That seam exists and is not yet
186
+ wired: no agent declares a pre-loaded type today, so a remote agent receives none.**
187
+ Stated in the present tense on purpose — it is a real limitation now, not a promise.
188
+
189
+ **The general lesson, which outlives this particular field:** when the same name means
190
+ different things on two paths, the one your tests exercise is the one you will believe.
191
+ Ask which path a green run actually walked.
192
+
193
+ ---
194
+
195
+ ## Context you get for free, and why it matters
196
+
197
+ The `## Context` block carries the user's language, locale, timezone, today's
198
+ date and their temporal context. You do not have to ask for any of it.
199
+
200
+ This exists because every one of these has shipped as a real defect:
201
+
202
+ | omitted | what the user got |
203
+ |---|---|
204
+ | language instruction | a German user answered in English |
205
+ | locale formatting | `03/04` meaning April 3rd to a Swiss reader |
206
+ | temporal context | "tomorrow" resolved to the wrong day |
207
+
208
+ **None of them fail a test you would think to write.** All of them reach a user,
209
+ and all of them get reported to us as Swisper bugs. That is why this block is
210
+ composed rather than left to you — not because you would get it wrong, but
211
+ because you would have to know it was there to get it right.