Skip to content
Open
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
25 changes: 25 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,31 @@
# each process mints its own token, which only works single-process-per-host.
# APOLLO_INTERNAL_TOKEN=

# Which characters a workflow step name may contain. Apollo sanitises step names
# on the way out and Lightning validates them on the way in, so the two rules
# have to agree.
#
# false (the default) ASCII only: letters, digits, spaces, hyphens and
# underscores. Accents are folded (Café -> Cafe) and
# anything else is dropped. Matches the rule Lightning
# enforces today.
# true Anything except control characters: letters and marks
# from any script, all punctuation and symbols, emoji,
# / : > & and quotes. Vérifier l'état and 患者確認
# survive exactly as typed.
#
# Leave this off until Lightning ships its Unicode step names (Lightning#4577),
# then turn it on. Turning it on first means Apollo emits names Lightning
# rejects; leaving it off afterwards means Apollo renames steps people typed
# deliberately.
#
# Both modes reject the same control set and nothing else: C0 (U+0000-U+001F,
# NUL included), DEL (U+007F), C1 (U+0080-U+009F), U+FFFE / U+FFFF, the
# surrogates U+D800-U+DFFF, and the separators U+2028 / U+2029. Names
# are NFC-normalised and capped at 100 graphemes in both modes.
# See services/name_rules.py.
APOLLO_UNICODE_STEP_NAMES=false

ANTHROPIC_API_KEY=sk-YOUR-API-KEY-HERE

OPENAI_API_KEY=sk-YOUR-API-KEY-HERE
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/unit-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,43 @@ jobs:
- name: Run unit tests
run: poetry run pytest services/*/tests/unit

unicode-parity:
name: Unicode parity with Elixir
runs-on: ubuntu-latest
timeout-minutes: 15

# `services/name_rules.py` carries tables generated from the Elixir that
# Lightning runs: grapheme break classes, Extended_Pictographic, combining
# classes, the trim set and OTP's NFC. If Elixir's or Python's Unicode
# version moves and nobody re-runs the harness, the tables silently stop
# matching and Apollo starts emitting step names Lightning rejects. This
# job is the only thing that would notice.
steps:
- uses: actions/checkout@v7

- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: "1.18.3"
otp-version: "27"

- name: Set up Python 3.11
uses: actions/setup-python@v7
with:
python-version: "3.11"

- name: Generate range edges from the tables
working-directory: tools/unicode_parity
run: python3 edges.py

- name: Probe Elixir
working-directory: tools/unicode_parity
run: elixir probe.exs

- name: Compare against name_rules
working-directory: tools/unicode_parity
run: python3 check.py

bun:
name: Bun unit tests
runs-on: ubuntu-latest
Expand Down
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,73 @@ full list of keys and env vars Apollo reads.
Also note that `tmp` dirs are untracked, so if you do want to store credentials
in your json, keep it inside a tmp dir and it'll remain safe and secret.

### `APOLLO_UNICODE_STEP_NAMES`

Controls which characters a workflow step name may contain. Apollo sanitises
step names on the way out and Lightning validates them on the way in, so the two
rules have to agree. The default is `false`.

| Value | Rule |
| --- | --- |
| `false` (default) | ASCII only. Letters, digits, spaces, hyphens, underscores. Accents are folded (`Café` becomes `Cafe`) and anything else is dropped. This is the rule Lightning enforces today. |
| `true` | Anything except control characters. Letters and marks from any script, all punctuation and symbols, emoji, `/`, `:`, `>`, `&`, quotes and apostrophes. `Vérifier l'état` and `患者確認` survive exactly as typed. |

Leave it off until Lightning ships Unicode step names (Lightning#4577), then
turn it on. Turning it on first means Apollo emits names Lightning rejects.
Leaving it off afterwards means Apollo renames steps people typed deliberately,
across the whole workflow, on every turn that returns YAML.

The permissive rule is deliberately maximal. Apollo being stricter than
Lightning is the worse of the two failures: Lightning rejecting a name is loud
and recoverable, whereas Apollo quietly renaming a valid name is the silent
vandalism this flag exists to prevent.

The rejected control set is the same in both modes: C0 (`U+0000`-`U+001F`,
NUL included), DEL (`U+007F`), C1 (`U+0080`-`U+009F`), the noncharacters
`U+FFFE` and `U+FFFF`, the surrogates `U+D800`-`U+DFFF`, and the line and
paragraph separators `U+2028` and `U+2029`. A NUL byte in a name crashes the Postgres insert on
Lightning's side. Names are NFC-normalised in both modes so that Apollo and
Lightning agree on how to spell an accent, which is what step lookup matches
on, and capped at 100 graphemes because that is what Ecto's `validate_length`
counts.

The rule lives in `services/name_rules.py`, and everything that states or
enforces it is derived from there: the sanitiser, the workflow-generation
prompt (`describe_rule_for_prompt`), the acceptance-test judges
(`describe_rule_for_judge`, substituted into the rubric markdown by
`judges.load_judge`), and the `assert_no_special_chars` test assertion. Change
the rule in that one file and all four follow.

The 100-character cap is counted in graphemes, because that is what Ecto's
`validate_length` counts on Lightning's side. The clustering is hand-written in
`name_rules`, with no third-party dependency, and it targets Elixir's
`String.length/1` rather than UAX #29 — Elixir deviates from the spec in two
places (it does not implement the Unicode 15.1 Indic conjunct rule, and it ends
an emoji ZWJ run at the joiner unless a pictograph follows) and the whole point
is to agree with Elixir, not with the spec.

`tools/unicode_parity` is the harness that checks it. Run `python3 edges.py`,
then `elixir probe.exs`, then `python3 check.py` with the Elixir version Lightning runs; `--tables`
prints the literals to paste back into `name_rules`. It checks five things:
every codepoint's break class, the `Extended_Pictographic` set, the trim set,
what a GB11 emoji run may be separated from its joiner by, and cluster
boundaries over a generated corpus. Normalisation is not among them:
`normalize_nfc` is the standard library's, so there is no table of ours to
check against Elixir.

`Extended_Pictographic` needs its own check because it is *not* a break class,
so a per-codepoint sweep cannot see it — an over-broad set there silently
changes clustering either side of a ZWJ and nothing else notices. That is
exactly how a hand-written table with 531 wrong codepoints survived two rounds
of review.

Re-run the harness whenever Python's or Elixir's Unicode version moves.
`name_rules.PARITY_SOURCE` records what the committed tables were generated
from, and a unit test pins it, so a silent regeneration fails loudly. Python
moving ahead only makes Apollo overcount, which truncates early; Elixir moving
ahead is the direction that reintroduces undercounting, and an undercount ships
a name Lightning rejects.

## Debugging

The server defaults to port 3000. You can test any service directly with curl to
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pythonpath = ["services"]

# Discovery roots. pytest walks these for test_*.py files.
testpaths = [
"services/echo/tests",
"services/global_chat/tests",
"services/workflow_chat/tests",
"services/job_chat/tests",
Expand All @@ -59,6 +60,9 @@ python_functions = ["test_*"]

markers = [
"unit: fast, isolated, no I/O. Runs on every PR push.",
# Declared but currently unused: nothing carries it, so `-m \"unit or service\"`
# is really `-m unit`. Kept because the tiers are referenced in the testing
# README; apply it when the first mocked-client suite lands.
"service: mocks HTTP/LLM clients; exercises service handlers. Runs on merge.",
"integration: hits real external services (LLM, Pinecone, Postgres). Manual/nightly.",
"acceptance: end-to-end acceptance criteria. Manual/nightly.",
Expand Down
19 changes: 15 additions & 4 deletions services/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ def _scrub_event(event: dict, _hint: dict) -> dict:
traces_sample_rate=trace_rates.get(env, 0.0),
enable_tracing=True,
auto_enabling_integrations=False,
# Frame locals are off. `before_send` scrubs by field name and value shape,
# but a stack frame carries whole objects: `data` holds `workflow_yaml` and
# `history`, and `preserved_values` on the workflow_chat stack is the
# placeholder-to-job-code map for every step. Sentry also walks
# `__cause__`/`__context__`, so a handler that logs only an exception type
# and re-raises still ships the original frame chain. Redacting job code
# from the prompt and then posting it to Sentry is the same leak.
include_local_variables=False,
before_send=_scrub_event,
# before_send covers error events only, and tracing is on.
before_send_transaction=_scrub_event,
Expand Down Expand Up @@ -94,15 +102,15 @@ def call(
sentry_sdk.capture_exception(e)
return _finish(
ApolloError(
code=500, message="Input file not found", type="INTERNAL_ERROR"
code=500, message="Input file not found", type="INTERNAL_ERROR",
).to_dict(),
output_path,
)
except json.JSONDecodeError as e:
sentry_sdk.capture_exception(e)
return _finish(
ApolloError(
code=500, message="Invalid JSON input", type="INTERNAL_ERROR"
code=500, message="Invalid JSON input", type="INTERNAL_ERROR",
).to_dict(),
output_path,
)
Expand All @@ -119,15 +127,18 @@ def call(
except ModuleNotFoundError as e:
sentry_sdk.capture_exception(e)
result = ApolloError(
code=500, message=str(e), type="INTERNAL_ERROR",
# Reached only when the service failed to build an ApolloError, and
# losing the message leaves the caller with nothing at all.
code=500, message=str(e), type="INTERNAL_ERROR", # safe-error-text: top-level fallback
).to_dict()
except ApolloError as e:
sentry_sdk.capture_exception(e)
result = e.to_dict()
except Exception as e:
sentry_sdk.capture_exception(e)
result = ApolloError(
code=500, message=str(e), type="INTERNAL_ERROR",
# As above.
code=500, message=str(e), type="INTERNAL_ERROR", # safe-error-text: top-level fallback
).to_dict()

langfuse.flush()
Expand Down
4 changes: 3 additions & 1 deletion services/global_chat/PAYLOAD_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ The `page` field is a simplified path/breadcrumb representing where the user is
workflows/<workflow-name>/<step-name>
```

The step name should match a job key in the workflow YAML (exact match or normalized — lowercase, non-alphanumeric chars replaced with hyphens). The backend parses the URL by splitting on `/` and reading the 3rd segment as the step name.
The step name should match a job key in the workflow YAML, either exactly or after normalization — NFC-normalized, lowercased, with every character that is not a letter, mark or digit replaced by a hyphen. Normalization is Unicode-aware, so `患者確認` normalizes to itself rather than to the empty string; a name that normalizes to nothing is never fuzzy-matched.

The backend parses the URL by splitting on `/` and taking everything after the workflow segment as the step name, so a step name containing a `/` survives. A workflow name containing a `/` still makes the split ambiguous, so the parsed step name is validated against the workflow YAML rather than trusted.

| Page URL | Router signal | What happens |
|---|---|---|
Expand Down
31 changes: 18 additions & 13 deletions services/global_chat/global_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,20 @@

This is the supervisor agent that coordinates subagents and tools.
"""
import os
from typing import Dict, Any, List, Optional
from dataclasses import dataclass

# Import utilities from parent services directory
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional

sys.path.append(str(Path(__file__).parent.parent))

from langfuse import observe, propagate_attributes, get_client as get_langfuse_client
from util import ApolloError, create_logger, APOLLO_VERSION
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 langfuse import get_client as get_langfuse_client
from langfuse import observe, propagate_attributes
from langfuse_util import build_generation_diff, build_tags, should_track
from util import APOLLO_VERSION, ApolloError, create_logger

logger = create_logger(__name__)

Expand Down Expand Up @@ -71,7 +71,8 @@ def main(data_dict: dict) -> dict:
try:
# 1. Validate payload
data = Payload.from_dict(data_dict)
logger.info(f"Global agent called with content: {data.content[:100]}...")
# Length only: this is the client's raw chat message.
logger.info(f"Global agent called with {len(data.content)} characters of content")

session_id = data.meta.get("session_id") if data.meta else None
user_info = (data.meta.get("user") or {}) if data.meta else {}
Expand Down Expand Up @@ -128,13 +129,17 @@ def main(data_dict: dict) -> dict:
"usage": result.usage,
"meta": {
**result.meta,
"apollo_version": APOLLO_VERSION
}
"apollo_version": APOLLO_VERSION,
},
}

except ApolloError as e:
logger.error(f"ApolloError in global_chat: {e}")
# Type and status only. An ApolloError raised further in wraps an
# arbitrary inner exception, and `subagent_caller` builds its message
# from `str(e)` — which can quote client-supplied `workflow_yaml` back.
logger.error(f"ApolloError in global_chat (code={e.code})")
raise e
except Exception as e:
logger.exception("Unexpected error in global_chat")
raise ApolloError(500, str(e))
logger.error(f"Unexpected error in global_chat ({type(e).__name__})")
# Returned to the caller as the error payload, so same treatment.
raise ApolloError(500, f"Unexpected error in global_chat ({type(e).__name__})")
Loading