diff --git a/packages/python/skills/writing-friday-python-agents/SKILL.md b/packages/python/skills/writing-friday-python-agents/SKILL.md index e7bee81..c71b82a 100644 --- a/packages/python/skills/writing-friday-python-agents/SKILL.md +++ b/packages/python/skills/writing-friday-python-agents/SKILL.md @@ -138,84 +138,15 @@ Currently only `stdio` transport is supported. If `ctx.tools.call` raises `ToolCallError("Unknown tool ...")`, list tools and fix the workspace/agent config rather than retrying a guessed name. -### Human input — `request_human_input` (platform tool) +### Platform-injected tools -When a user agent needs a decision, approval, or disambiguation, call the -platform tool instead of streaming a question and hoping a later chat turn -resumes the process: - -```python -choice = ctx.tools.call("request_human_input", { - "question": "What should I do with these messages?", - "options": [ - {"label": "Archive", "value": "archive"}, - {"label": "Keep", "value": "keep"}, - ], -}) -# returns JSON text/content with status, answer, and elicitationId -``` - -The host creates an Activity/sidebar `open-question` elicitation, blocks this -same tool call until the user answers/declines/expires, then resumes the agent -with the answer. Use this for interactive review workflows; do not implement a -local polling loop or direct `/api/elicitations` HTTP calls. - -### Memory — `save_memory_entry` / `list_memory_entries` (platform tools) - -The host injects platform memory tools into every agent's tool surface -automatically — no MCP declaration needed. Most-used: - -```python -# Append a single fact. The store handles persistence + ordering. -ctx.tools.call("save_memory_entry", { - "memoryName": "preferences", - "text": "Always archive newsletters from substack.com", -}) - -# Read recent entries. (The 20 most recent narrative entries are -# auto-injected into the LLM-side system prompt every turn — Python -# agents don't see those, so call list_memory_entries explicitly when you need -# durable state across runs.) -result = ctx.tools.call("list_memory_entries", { - "memoryName": "preferences", - "limit": 50, -}) -``` - -**Append semantics — one call per fact, never read-concat-write.** - -```python -# ✅ Correct — one fact per call. Concurrent writers compose cleanly. -for fact in new_preferences: - ctx.tools.call("save_memory_entry", {"memoryName": "preferences", "text": fact}) - -# ❌ Wrong — read-concat-write. Concurrent writers clobber each other, -# fights the platform's append/dedup logic, and the next run starts -# from a stale snapshot. -existing = ctx.tools.call("list_memory_entries", {"memoryName": "preferences"}) -combined = existing["text"] + "\n" + "\n".join(new_preferences) -ctx.tools.call("save_memory_entry", {"memoryName": "preferences", "text": combined}) -``` - -**Footgun: `ToolCallError` on validation failure.** `ctx.tools.call` -raises if the store isn't declared in `workspace.yml`, if the entry -exceeds size limits, or if the host rejects the write. Never swallow -with bare `except Exception` — that silently drops writes. Surface -the error through `err()` or let it propagate. - -```python -from friday_agent_sdk import ToolCallError - -try: - ctx.tools.call("save_memory_entry", {"memoryName": "preferences", "text": fact}) -except ToolCallError as e: - return err(f"save_memory_entry failed: {e}") -``` - -**Stores must use the `narrative` strategy.** Friday's runtime today -only implements narrative storage; stores declared with other strategies -(`retrieval`, `dedup`, `kv`) exist in the schema but throw at write time. -If you're authoring a workspace, just use narrative. +The host may inject additional tools into `ctx.tools` beyond what you declared +in `mcp={...}` — workflows like human-input elicitations, memory writes, +artifact handling. These are platform features, not SDK features; their names, +arg shapes, and semantics belong to whichever platform you're running on. Call +`ctx.tools.list()` at runtime to see what's actually available, and consult the +platform's own docs for which tools to call. The SDK contract is just +`ctx.tools.call(name, args) -> dict` and `ToolCallError` on failure. ### ctx.stream — Progress Events @@ -232,48 +163,46 @@ Emit progress _before_ expensive operations so the UI shows what's happening. A `type: user` agent receives input through one of two channels. Pick by how the job is wired, not by preference: -- **Signal payload** — the fields the job's trigger signal was fired with, - plus any `{{inputs.}}` the FSM action interpolated. These arrive in - the **`prompt`** string. Parse them with `parse_input(prompt)`. `ctx.input` - does NOT carry the signal payload — a job triggered by a signal with no - upstream producer has an empty `ctx.input`. -- **Upstream step output** — a prior FSM step's `outputTo` document, wired - into this action via `inputFrom`. These arrive in **`ctx.input`**, keyed by - the producing document's id. Use `ctx.input.get("doc-id")`. +| Channel | Where it arrives | Read with | Use for | +| -------------------- | ---------------- | -------------------------- | ------------------------------------------------ | +| Signal payload | `prompt` string | `parse_input(prompt, ...)` | Fields the trigger signal was fired with | +| Upstream step output | `ctx.input` | `ctx.input.get("doc-id")` | `outputTo`/`inputFrom` handoff from a prior step | -If you guessed a key for `ctx.input.get(...)` and got nothing back, you almost -certainly wanted the signal payload — read `prompt` with `parse_input` instead. +`ctx.input` does NOT carry the signal payload — a job triggered by a signal +with no upstream producer has an empty `ctx.input`. If you guessed a key for +`ctx.input.get(...)` and got nothing back, you almost certainly wanted the +signal payload — read `prompt` with `parse_input` instead. -### ctx.input — Runtime-provided action input +### ctx.input — Upstream step output -When the action declares `inputFrom`, prefer `ctx.input` over prompt scraping -to consume that upstream data. Producers may compact bulky outputs into summary + -artifact refs; downstream Python agents should dereference those refs through -host capabilities, not ask the producer to inline large payloads. +When the action declares `inputFrom`, use `ctx.input` rather than scraping the +prompt. Producers may compact bulky outputs into summary + artifact refs; +downstream agents dereference those refs through host capabilities instead of +asking the producer to inline large payloads. ```python -payload = ctx.input.artifact_json("fetched-emails") -emails = payload.get("emails", []) +# Compact value (whatever the upstream step put in its outputTo doc) +payload = ctx.input.get("emails-result") -return ok({"count": len(emails), "firstId": emails[0]["id"] if emails else None}) +# Or, when the doc carries artifact refs, hydrate the JSON contents +payload = ctx.input.artifact_json("emails-result") +emails = payload.get("emails", []) +return ok({"count": len(emails)}) ``` -Useful methods: +Useful methods (all take an optional `doc-id`; omit it to operate on the full +raw input): -- `ctx.input.get("doc-id")` — compact input payload for an `inputFrom` document. -- `ctx.input.require("doc-id")` — same, but raises if missing. -- `ctx.input.artifact_refs("doc-id")` — artifact refs attached to the input. -- `ctx.input.artifact_json("doc-id")` — fetches via `get_artifact` and parses JSON contents. +- `ctx.input.get(name, default=None)` — compact input payload for an `inputFrom` document. +- `ctx.input.require(name)` — same, but raises `ValueError` if missing. +- `ctx.input.artifact_refs(name)` — artifact refs attached to the input. +- `ctx.input.artifact_json(name)` — fetch via `get_artifact` and parse JSON contents. `prompt` still exists for natural-language task instructions and backwards compatibility, but `parse_input(prompt)` is not the right abstraction for multi-step `outputTo`/`inputFrom` handoffs. -Friday sends "enriched prompts" — markdown with embedded JSON containing task -details, signal data, and context. Code agents can still extract structured data -from these for simple one-shot prompts. - -### parse_input — Simple extraction +### parse_input — Signal-payload extraction ```python from friday_agent_sdk import parse_input @@ -372,55 +301,16 @@ When in doubt: if the operation touches a network boundary or needs credentials, use the host capability. If it's pure data manipulation, standard library or installed packages are fine. -## Getting Agents Into Friday - -### Register via the daemon HTTP API - -Register your agent by POSTing the entrypoint's absolute path to the daemon: - -```bash -curl -X POST http://localhost:8080/api/agents/register \ - -H 'Content-Type: application/json' \ - -d '{"entrypoint": "/abs/path/to/your-agent/agent.py"}' -``` - -`entrypoint` must be an absolute path. The daemon spawns it with -`FRIDAY_VALIDATE_ID`, collects metadata over NATS, copies the source directory -into the agents registry (under `{FRIDAY_HOME}/agents/{id}@{version}/` — the -home dir is mid-migration from `~/.atlas` to `~/.friday/local`), and reloads -the registry. No compilation step — the agent process is spawned per -invocation and communicates with the host via NATS request/reply. The daemon sets `FRIDAY_NATS_URL` for registration and execution; agent code should not open its own NATS connection or assume `nats://localhost:4222`. - -The register response returns `agent.path` (the install dir). To look up the -source path of an existing agent, query `GET /api/agents/:id` and read -`sourceLocation` rather than constructing the path from a constant. +## Getting Agents Into a Workspace -The Friday daemon listens on `localhost:8080` by default (configurable via -the `FRIDAY_PORT` env var or the `--port` flag if you started the daemon -manually). +The SDK's job ends at the file: a registered `@agent` function plus +`run()` in `__main__`. How that file becomes a usable agent on the host +is a platform concern (Friday Studio's CLI, an in-tree dev daemon, a +deployment pipeline) — see your platform's docs for the registration +command, and do not have the agent code shell out to a daemon to +register itself. -### Test directly - -Execute an agent without going through the full FSM pipeline. Replace -`my-agent` with your agent id (the `id=` value from the `@agent` decorator): - -```bash -curl -s -X POST "http://localhost:8080/api/agents/my-agent/run?workspaceId=user" \ - -H 'Content-Type: application/json' \ - -d '{"input": "test prompt"}' -``` - -Or via the playground API on `localhost:5200`: - -```bash -curl -s -X POST http://localhost:5200/api/agents/my-agent/run \ - -H 'Content-Type: application/json' \ - -d '{"input": "test prompt"}' -``` - -### Workspace Configuration - -Register your agent in `workspace.yml`: +Once the platform knows about it, reference it in `workspace.yml`: ```yaml agents: @@ -428,14 +318,20 @@ agents: type: user ``` -Friday adds the `user:` prefix automatically — you specify `my-agent`, -Friday resolves it to `user:my-agent`. +The `id` matches the `id=` value from the `@agent` decorator. The host +resolves `type: user` to the registered Python agent and routes +invocations into your `execute` function over NATS. + +The agent process is spawned per invocation by the host; it sets +`FRIDAY_VALIDATE_ID` (registration) or `FRIDAY_SESSION_ID` (execution) +plus `NATS_URL`, and `run()` handles the rest. Your code should not +open its own NATS connection. ## Casing Convention This is a subtle but real source of bugs: -- **Decorator kwargs**: `snake_case` (Pythonic) — `display_name`, `input_schema`, `use_workspace_skills` +- **Decorator kwargs**: `snake_case` (Pythonic) — `display_name`, `use_workspace_skills` - **Dict values inside decorator**: `camelCase` (matches host Zod schemas) — `linkRef`, `displayName` inside environment dicts - **MCP config keys**: `camelCase` in transport config - **Result data**: your choice, but `snake_case` is conventional for Python agents diff --git a/packages/python/skills/writing-friday-python-agents/references/api.md b/packages/python/skills/writing-friday-python-agents/references/api.md index f193516..a89b6bb 100644 --- a/packages/python/skills/writing-friday-python-agents/references/api.md +++ b/packages/python/skills/writing-friday-python-agents/references/api.md @@ -10,6 +10,7 @@ Complete reference for the `friday_agent_sdk` Python package. - [ctx.llm — Llm](#ctxllm) - [ctx.http — Http](#ctxhttp) - [ctx.tools — Tools](#ctxtools) +- [ctx.input — AgentInput](#ctxinput) - [ctx.stream — StreamEmitter](#ctxstream) - [Response Types](#response-types) - [Result Types](#result-types) @@ -22,25 +23,28 @@ Complete reference for the `friday_agent_sdk` Python package. ```python from friday_agent_sdk import ( - # Decorator + # Decorator + entry point agent, - - # Entry point run, # Parsing parse_input, parse_operation, - # Result constructors - ok, - err, - - # Result types - OkResult, ErrResult, AgentResult, + # Result constructors + types + ok, err, + OkResult, ErrResult, AgentResult, AgentExtras, + ArtifactRef, OutlineRef, - # Context + # Context + structured input AgentContext, + AgentInput, InputArtifactRef, + SessionData, + SkillDefinition, + + # Capability classes (rarely constructed by user code; useful for typing) + Llm, Http, Tools, StreamEmitter, + ToolDefinition, # Response types LlmResponse, HttpResponse, @@ -48,8 +52,8 @@ from friday_agent_sdk import ( # Exceptions LlmError, HttpError, ToolCallError, - # Streaming - StreamEmitter, + # Version + __version__, ) ``` @@ -67,15 +71,25 @@ def agent( summary: str | None = None, # One-line summary constraints: str | None = None, # What the agent cannot do examples: list[str] | None = None, # Example prompts that invoke this agent - input_schema: type | None = None, # Dataclass type — extracted as JSON Schema for docs - output_schema: type | None = None, # Dataclass type — passed to ctx.output_schema at runtime environment: dict[str, Any] | None = None, # Required/optional env vars mcp: dict[str, Any] | None = None, # MCP server configurations llm: dict[str, Any] | None = None, # Default LLM provider/model - use_workspace_skills: bool = False, # Access workspace-level skills + use_workspace_skills: bool = False, # When True, host populates ctx.skills with workspace skills ) -> Callable ``` +The decorator also accepts `input_schema: type | None` and `output_schema: +type | None` for forward compatibility, but neither is currently serialized to +the host — they are no-ops today. `ctx.output_schema` (below) is host-driven +and unrelated to these kwargs. + +### use_workspace_skills + +When set to `True`, the host attaches the workspace's resolved skills to +`ctx.skills` (a `list[SkillDefinition]` of `{name, description, instructions}`). +Leave it `False` if your agent does not consult workspace-defined skills — the +list is empty when this flag is off. + ### environment ```python @@ -129,17 +143,19 @@ llm={ ```python @dataclass class AgentContext: - env: dict[str, str] # Environment variables (always present, may be empty) - config: dict # Agent-specific config from workspace (always present) - session: SessionData | None # Session metadata - output_schema: dict | None # JSON Schema from output_schema decorator param - tools: Tools # MCP tool capability (always initialized) - llm: Llm # LLM generation capability (always initialized) - http: Http # HTTP fetch capability (always initialized) - stream: StreamEmitter # Progress streaming capability (always initialized) + env: dict[str, str] # Environment variables (always present, may be empty) + config: dict # Agent-specific config from workspace (always present) + skills: list[SkillDefinition] # Workspace skills when use_workspace_skills=True, else [] + session: SessionData | None # Session metadata + output_schema: dict | None # JSON Schema sent by the host on execute (e.g., from an FSM action's outputType); None when the host sends nothing + input: AgentInput # Structured action input (always initialized) + tools: Tools # MCP tool capability (always initialized) + llm: Llm # LLM generation capability (always initialized) + http: Http # HTTP fetch capability (always initialized) + stream: StreamEmitter # Progress streaming capability (always initialized) ``` -`env` and `config` are guaranteed non-None. Capabilities are always initialized; they may be stubs in test contexts that raise `RuntimeError` if called without proper setup. +`env`, `config`, `skills`, and `input` are guaranteed non-None. Capabilities are always initialized; they may be stubs in test contexts that raise `RuntimeError` if called without proper setup. ### SessionData @@ -152,6 +168,18 @@ class SessionData: datetime: str # ISO datetime string ``` +### SkillDefinition + +```python +@dataclass +class SkillDefinition: + name: str + description: str + instructions: str +``` + +Populated on `ctx.skills` when the agent is declared with `use_workspace_skills=True`. + --- ## ctx.llm @@ -236,6 +264,56 @@ Raises `ToolCallError` on failure. --- +## ctx.input + +Structured runtime input. `ctx.input` is the typed counterpart to the +`prompt` string: it exposes the compact `inputFrom`/config payload without +asking agents to scrape JSON out of markdown. + +`raw` is populated from the host's execute payload (the `context.input` +section of the NATS execute message). When the host sends nothing structured, +`raw` is `{}` and every accessor returns the same empty-state shape. + +```python +class AgentInput: + raw: dict # Full structured input as supplied by the host + config: dict # Shortcut for raw.get("config", {}) — the inputFrom-keyed dict + + def get(name: str | None = None, default: Any = None) -> Any: ... + def require(name: str | None = None) -> Any: ... + def artifact_refs(name: str | None = None) -> list[InputArtifactRef]: ... + def artifact_json(name: str | None = None) -> Any: ... +``` + +Behavior notes: + +- `get(name)` looks first in `raw["config"][name]` (the usual `inputFrom` + shape) and falls back to `raw[name]`. Returns `default` when missing. +- `get()` with no name returns the full `raw` dict. +- `require(name)` raises `ValueError` if missing; `require()` with no name + raises if `raw` is empty. +- `artifact_refs(name)` walks the selected payload and collects any + `artifactRef` / `artifactRefs` entries into a deduplicated list. Useful + when you want to introspect refs before fetching. +- `artifact_json(name)` resolves those refs through `get_artifact` and + returns the parsed JSON — a single payload if one ref, a list if many. + Raises `ValueError` when no refs are present and `ToolCallError` when + `ctx.tools` is not initialized. + +```python +@dataclass(frozen=True) +class InputArtifactRef: + id: str + type: str = "Artifact" + summary: str = "" +``` + +`ctx.input` is intended for `inputFrom` upstream-step output. Signal-payload +fields the trigger fired with arrive in the `prompt` string — parse them with +`parse_input(prompt, ...)` instead. + +--- + ## ctx.stream ### progress() diff --git a/packages/python/skills/writing-friday-python-agents/references/constraints.md b/packages/python/skills/writing-friday-python-agents/references/constraints.md index 77c6202..38bde92 100644 --- a/packages/python/skills/writing-friday-python-agents/references/constraints.md +++ b/packages/python/skills/writing-friday-python-agents/references/constraints.md @@ -4,7 +4,6 @@ - [Environment and Extension Guidance](#environment-and-extension-guidance) - [Casing Rules](#casing-rules) -- [Build Pipeline](#build-pipeline) - [Common Mistakes and Fixes](#common-mistakes-and-fixes) - [One Agent Per Module](#one-agent-per-module) - [Streaming LLM Responses](#streaming-llm-responses) @@ -53,12 +52,12 @@ The SDK spans a Python/JavaScript boundary. Casing conventions differ on each si ### Your code (Python side) -| Context | Convention | Example | -| ---------------- | ------------ | ------------------------------------------------------ | -| Decorator kwargs | `snake_case` | `display_name`, `input_schema`, `use_workspace_skills` | -| Dataclass fields | `snake_case` | `issue_key`, `max_results` | -| Function names | `snake_case` | `_handle_view`, `_build_auth` | -| Variable names | `snake_case` | `api_key`, `response_data` | +| Context | Convention | Example | +| ---------------- | ------------ | -------------------------------------- | +| Decorator kwargs | `snake_case` | `display_name`, `use_workspace_skills` | +| Dataclass fields | `snake_case` | `issue_key`, `max_results` | +| Function names | `snake_case` | `_handle_view`, `_build_auth` | +| Variable names | `snake_case` | `api_key`, `response_data` | ### Dict values passed to host @@ -74,27 +73,11 @@ The `_bridge.py` module converts decorator metadata to camelCase when serializin - `display_name` → `displayName` - `use_workspace_skills` → `useWorkspaceSkills` -- `input_schema` → `inputSchema` (after JSON Schema extraction) You don't need to worry about this conversion — just use snake_case in Python and the bridge handles it. --- -## Build Pipeline - -`atlas agent register` handles the full pipeline: - -``` -agent.py → spawn with FRIDAY_VALIDATE_ID → metadata.json over NATS - → copy source dir to ~/.friday/local/agents/{id}@{version}/ - → write metadata.json sidecar - → reload registry -``` - -You don't run NATS directly. If registration fails, the error message includes the phase (`prereqs`, `validate`, `write`) and details. - ---- - ## Common Mistakes and Fixes ### Missing `run()` entry point diff --git a/packages/python/skills/writing-friday-python-agents/references/examples.md b/packages/python/skills/writing-friday-python-agents/references/examples.md index 2c551be..e487ce3 100644 --- a/packages/python/skills/writing-friday-python-agents/references/examples.md +++ b/packages/python/skills/writing-friday-python-agents/references/examples.md @@ -10,6 +10,7 @@ capability pattern. - [Tier 3: HTTP API Integration](#tier-3-http-api-integration) - [Tier 4: MCP Tools](#tier-4-mcp-tools) - [Tier 5: Multi-Operation Dispatch](#tier-5-multi-operation-dispatch) +- [Tier 6: Consuming Upstream Step Output (ctx.input)](#tier-6-consuming-upstream-step-output) - [Patterns Summary](#patterns-summary) --- @@ -309,14 +310,95 @@ Key points: --- +## Tier 6: Consuming Upstream Step Output + +When the action is wired into an FSM with `inputFrom: `, the upstream +step's output arrives in `ctx.input`, NOT in `prompt`. Use `ctx.input.get(...)` +for compact payloads and `ctx.input.artifact_json(...)` when the upstream +producer compacted bulky data into artifact refs. + +Workspace wiring (for context — this is what makes `ctx.input` populated): + +```yaml +jobs: + daily-brief: + fsm: + initial: idle + states: + idle: + on: { run-brief: { target: fetch } } + fetch: + entry: + - type: agent + agentId: gmail-fetcher + outputTo: emails-result + - type: emit + event: DONE + on: { DONE: { target: count } } + count: + entry: + - type: agent + agentId: email-counter # ← the agent below + inputFrom: emails-result + type: final +``` + +The downstream agent: + +```python +from friday_agent_sdk import agent, ok, err, AgentContext, ToolCallError, run + +@agent( + id="email-counter", + version="1.0.0", + description="Counts emails from an upstream fetcher and returns top-line stats", +) +def execute(prompt: str, ctx: AgentContext): + # First try the compact value the producer wrote into outputTo + payload = ctx.input.get("emails-result") + + # When the producer compacted bulky data into an artifact, hydrate it + if not isinstance(payload, dict) or "emails" not in payload: + try: + payload = ctx.input.artifact_json("emails-result") + except (ValueError, ToolCallError) as e: + return err(f"No upstream emails to count: {e}") + + emails = payload.get("emails", []) if isinstance(payload, dict) else [] + return ok({ + "count": len(emails), + "first_subject": emails[0].get("subject") if emails else None, + }) + + +if __name__ == "__main__": + run() +``` + +Key points: + +- `ctx.input.get("doc-id")` returns the upstream step's compact `outputTo` + payload. The `doc-id` is the upstream `outputTo` value, not a guess. +- `ctx.input.artifact_json("doc-id")` resolves artifact refs the producer + attached, fetching the underlying JSON through `get_artifact`. Use this + when the compact payload is a summary + refs rather than the full data. +- An empty `ctx.input` means the action wasn't wired with `inputFrom` — check + the FSM job, or look in `prompt` if the data was passed on the signal + payload instead. +- Do NOT ask upstream producers to inline large payloads to avoid `ctx.input`; + hydrate refs in the consumer instead. + +--- + ## Patterns Summary -| Pattern | When to use | Key imports | -| ------------------ | -------------------------------------------- | ------------------------------------ | -| Echo/passthrough | Testing, simple transforms | `ok, run` | -| LLM generation | Text analysis, classification, summarization | `ok, err, LlmError, run` | -| HTTP integration | External API calls | `ok, err, HttpError, run` | -| MCP tools | Pre-built service integrations | `ok, err, ToolCallError, run` | -| Multi-operation | Agents handling multiple distinct tasks | `ok, err, parse_operation, run` | -| Structured output | When you need typed JSON from LLM | `generate_object` + JSON Schema dict | -| Streaming progress | Long-running tasks | `ctx.stream.progress()` | +| Pattern | When to use | Key imports | +| ------------------- | -------------------------------------------- | ------------------------------------ | +| Echo/passthrough | Testing, simple transforms | `ok, run` | +| LLM generation | Text analysis, classification, summarization | `ok, err, LlmError, run` | +| HTTP integration | External API calls | `ok, err, HttpError, run` | +| MCP tools | Pre-built service integrations | `ok, err, ToolCallError, run` | +| Multi-operation | Agents handling multiple distinct tasks | `ok, err, parse_operation, run` | +| Upstream-step input | Agent is wired with `inputFrom: ` | `ctx.input.get / artifact_json` | +| Structured output | When you need typed JSON from LLM | `generate_object` + JSON Schema dict | +| Streaming progress | Long-running tasks | `ctx.stream.progress()` |