Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/planner-page-prefix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"apollo": patch
---

global_chat: planner turns now record the `[pg:...]` page prefix in the returned history, like the direct routes, so every route returns history in the same shape
5 changes: 5 additions & 0 deletions .changeset/skills-as-slash-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"apollo": minor
---

global_chat: accept a `skill` field naming a standard skill the user invoked by slash command. `/diagnose` and `/qa` ship with the agent; an invoked skill skips the router and leads the planner's turn. Skill instructions are kept in the returned history, so the client sends `skill` only on the turn that invokes it
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,10 @@ SSE to clients.
- `global_chat/` - Orchestrator service and single entry point for OpenFn AI
chat. Routes requests via a RouterAgent (Haiku) to specialized subagents, or
escalates to a PlannerAgent (Sonnet) that coordinates multi-step tasks using
tool calls. Depends on `job_chat`, `workflow_chat`, and `search_docsite`.
tool calls. A `skill` in the payload names a standard skill the user invoked by
slash command (`skills/<name>/SKILL.md`, loaded by `skill_registry.py`); it
bypasses the router and leads the planner's turn. Depends on `job_chat`,
`workflow_chat`, and `search_docsite`.
- `job_chat/` - AI chat service for OpenFn job code assistance. Supports
conversational help and a code suggestions mode with auto-patching. Uses RAG
via `search_docsite` and injects adaptor API docs. Streams responses.
Expand Down
63 changes: 61 additions & 2 deletions services/global_chat/PAYLOAD_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ This document defines the input and output payload structure for the Global Agen
}
],

"skill": { // Skill invoked this turn (optional)
"name": "diagnose" // Standard skill name: "design" | "diagnose" | "qa"
},

"options": { // Runtime options (optional)
"stream": false,
"web_search": false
Expand All @@ -65,7 +69,7 @@ This document defines the input and output payload structure for the Global Agen

- **`metrics_opt_in`** (boolean, optional): If `true`, enables Langfuse tracing for this session. The frontend is responsible for setting this; the backend tracks if and only if this flag is `true`.

- **`history`** (array, optional): Conversation history. Each turn has `role` and `content`. History is managed and returned by each agent internally.
- **`history`** (array, optional): Conversation history. Each turn has `role` and `content`. Send back the `history` from the previous response unchanged: Apollo owns its contents, which include `[pg:...]` page prefixes and any skill instructions in use.

- **`attachments`** (array, optional): Input attachments providing additional context for the request. Each entry has a `type` and `content` field. Useful for passing logs, dataclips, run inputs/outputs, or other contextual data that the agent can use when processing the request. Currently supported types:
- `log` — execution logs from a run
Expand All @@ -81,6 +85,11 @@ This document defines the input and output payload structure for the Global Agen

The `type` is passed to the model as a label, so an unrecognised type is delivered rather than dropped. See [Attachment handling](#attachment-handling) for how they travel and why they are not persisted.

- **`skill`** (object, optional): A standard skill the user invoked by slash
command. `name` is the skill's name (`"design"`, `"diagnose"` or `"qa"`). An unknown name
is rejected with a `400` `UNKNOWN_SKILL`. See
[Skill invocation](#skill-invocation).

- **`options`** (object, optional): Runtime options.
- **`stream`** (boolean): Enable streaming response (default: false).
- **`web_search`** (boolean): Let the planner search and fetch pages on the live web for this request (default: `false`). Takes effect **only on planner-routed requests** — the direct `workflow_agent` / `job_code_agent` routes ignore it, and `meta.web_search_requested` records when it was set on a request that never reached the planner. Reachable domains are limited to a server-side allowlist. Requires the caller's own Anthropic key to have web search enabled in their Anthropic Console; searches bill to that key, and clients on a zero-data-retention contract cannot use it. If the key does not have it enabled, the turn still answers — without web results — and sets `meta.web_search_downgraded`.
Expand Down Expand Up @@ -172,7 +181,7 @@ Each tool beat streams as: `thinking` spinner → `changes` (if the workflow was

- **`attachments`** (array): Artifacts produced during this turn. Each entry has a `type` and `content` field. An empty list `[]` means no artifacts were produced (e.g. a purely informational response). The only supported type is `workflow_yaml`: the full workflow YAML with any job code changes stitched in. Job code edits are never returned separately — the YAML is the single source of truth, which allows multi-step changes in one response.

- **`history`** (array): Updated conversation history including the latest exchange. Each entry has `content` as a string on every route. On the planner path the assistant entry contains only the final answer text — the pre-tool narration segments in `response` are not persisted to history.
- **`history`** (array): Updated conversation history including the latest exchange, in the shape the next request takes as input. Each entry has `content` as a string on every route, and every user entry carries a `[pg:...]` prefix naming the page it was sent from. On the planner path the assistant entry contains only the final answer text — the pre-tool narration segments in `response` are not persisted to history.

- **`usage`** (object): Token usage aggregated across all agents invoked (router + planner + sub-agents).

Expand All @@ -183,6 +192,7 @@ Each tool beat streams as: `thinking` spinner → `changes` (if the workflow was
- **`tool_calls`** (array): List of `{tool, input}` objects for each tool the planner invoked (planner path only).
- **`subagent_calls`** (array): Raw sub-agent result dicts including `_call_metadata`. On the planner path these are the full results, useful for debugging. On the router's direct job-code path it carries a single entry with just `_call_metadata` and `diff`, so a client can tell on either route whether a code edit actually landed (`diff.patches_applied`).
- **`total_tool_calls`** (number): Total number of tool calls made by the planner (planner path only).
- **`skill`** (string): The skill invoked this turn (skill path only). `router_confidence` is absent on this path, and on follow-ups to a skill, because the router did not run.
- **`truncated`** (boolean): `true` when the planner spent its `max_pause_continuations` budget while the API still had more of the turn to send `response` is the head of a reply the server split and not a finished answer. Accompanied by **`stop_reason`** (`"pause_turn"`).
- **`web_search_requested`** (boolean): Present and `true` only when the request set `options.web_search`.
- **`web_searches`** / **`web_fetches`** (number): Server-side web search and web fetch calls the planner made this turn.
Expand All @@ -191,6 +201,55 @@ Each tool beat streams as: `thinking` spinner → `changes` (if the workflow was

---

## Skill invocation

A skill is a reusable instruction set that augments the model's context from
the turn the user invokes it. Standard skills ship with Apollo, in
`services/global_chat/skills/<name>/SKILL.md`; they are immutable and upgrade
for everyone on deploy. The planner can also load a skill itself, through its
`load_skill` tool; that needs nothing from the client.

The client recognises the command and names it in `skill` — **Apollo never
parses commands out of `content`**. Follow standard slash-command semantics:
recognise `/<name>` only at the start of the message, against the known list;
the rest of the message is the request; one skill per message; the raw message
stays in the client's visible transcript.

Sending `skill` changes the turn in three ways:

1. **The router is skipped.** A slash command states the intent the router
would otherwise guess, so the turn goes straight to the planner. `meta.skill`
names the skill; `meta.router_confidence` is absent.
2. **The leading `/<name>` token is stripped** from `content` before the model
or the returned history sees it. The rest of the message is untouched.
3. **The skill's instructions lead the user turn.**

Unlike attachments, the instructions are written into that user turn in the
returned `history`, as in a standard agent transcript, so they keep applying on
later turns. **Send `skill` only on the turn that invokes it.** A skill the
planner loads itself is kept the same way. While a skill is in the history,
every turn goes to the planner without a routing call, since the direct agents
never saw it.

### Example

```json
{
"content": "/diagnose why did the last run fail?",
"skill": { "name": "diagnose" },
"workflow_yaml": "name: My Workflow\njobs:\n fetch-data:\n ...\n",
"attachments": [
{ "type": "log", "content": "ERROR: Request failed with status 500" }
]
}
```

The planner is asked `"why did the last run fail?"` with the `diagnose`
instructions ahead of it, and `meta` comes back as
`{"agents": ["planner"], "skill": "diagnose", ...}`.

---

## Page URL Format and Routing Behaviour

The `page` field is a simplified path/breadcrumb representing where the user is in the app. It uses a **3-segment format** with no leading slash:
Expand Down
16 changes: 15 additions & 1 deletion services/global_chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,18 @@ Request to build a new multi-step workflow from scratch:

## Implementation

### Skills

A `skill` field in the payload names a standard skill the user invoked by slash
command (`/diagnose`, `/qa`). Standard skills live in `skills/<name>/SKILL.md`,
in Anthropic's Agent Skills format, and are loaded by `skill_registry.py` at
import.

An invoked skill skips the router — a slash command states the intent the router
would otherwise guess — and its instructions lead the planner's user turn. They
are kept in the returned history, so they keep applying on later turns, which
also go to the planner. See [PAYLOAD_SPEC.md](PAYLOAD_SPEC.md#skill-invocation).

### Routing

Every request first passes through the `RouterAgent` (Claude Haiku), which
Expand Down Expand Up @@ -168,13 +180,15 @@ For straightforward requests, the router calls subagents directly:
### Planner

For complex requests, the `PlannerAgent` (Claude Opus) runs an agentic
tool-calling loop with access to four tools:
tool-calling loop with access to five tools:

- **`call_workflow_agent`** — create or modify workflow YAML structure
- **`call_job_code_agent`** — write or edit job code for a specific job
(requires an existing workflow with that job defined)
- **`search_documentation`** — semantic search over the OpenFn docsite
- **`inspect_job_code`** — read-only inspection of a job's current code
- **`load_skill`** — load a standard skill's instructions when a request
matches it

The planner always calls `call_workflow_agent` first to establish the structure,
then calls `call_job_code_agent` for each job that needs code. Job code is
Expand Down
19 changes: 19 additions & 0 deletions services/global_chat/global_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from langfuse_util import should_track, build_tags, build_generation_diff
from global_chat.config_loader import ConfigLoader
from global_chat.router import RouterAgent
from global_chat.skill_registry import Skill, get_skill

logger = create_logger(__name__)

Expand All @@ -33,6 +34,7 @@ class Payload:
api_key: Optional[str] = None
attachments: Optional[List[Dict]] = None
metrics_opt_in: Optional[bool] = None
skill: Optional[Skill] = None

@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Payload":
Expand All @@ -55,8 +57,18 @@ def from_dict(cls, data: Dict[str, Any]) -> "Payload":
api_key=data.get("api_key"),
attachments=data.get("attachments"),
metrics_opt_in=data.get("metrics_opt_in"),
skill=cls._resolve_skill(data.get("skill")),
)

@staticmethod
def _resolve_skill(raw: object) -> Optional[Skill]:
"""Resolve an invoked skill to its instructions, or reject the request."""
if raw is None:
return None
if not isinstance(raw, dict) or not isinstance(raw.get("name"), str):
raise ApolloError(400, "skill must be an object with a name")
return get_skill(raw["name"])

def get_stream(self) -> bool:
"""Extract stream flag from options."""
return (self.options or {}).get("stream", False)
Expand Down Expand Up @@ -112,6 +124,7 @@ def main(data_dict: dict) -> dict:
attachments=data.attachments or [],
user=user_info,
metrics_opt_in=data.metrics_opt_in,
skill=data.skill,
web_search=data.get_web_search(),
)

Expand All @@ -129,6 +142,12 @@ def main(data_dict: dict) -> dict:
if diff_meta:
langfuse.update_current_span(metadata=diff_meta)

# Tags, so traces can be filtered by the skills a turn used
skills_used = result.meta.get("skills")
if skills_used:
with propagate_attributes(tags=[f"skill:{name}" for name in skills_used]):
pass

# 5. Return structured response
return {
"response": result.response,
Expand Down
Loading
Loading