Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ for the http API surface, `tests/server/per_customer/` IS the per_customer stack
- `bash scripts/deploy.sh image` — the agent-container deploy: build → push (auto-incremented immutable `vNN` tag) → CLI-update a gerp's runtime to the new digest, from the copy ECR replication makes in the gerp's own region, once it has arrived (FULL config carried — UpdateAgentRuntime replaces wholesale) → wait READY → re-pin the named endpoint (DEFAULT auto-tracks). The gerp is gradienterp by default (the dogfood takes an image first), `--gerp <id>` for another, `--all` for every active gerp — a fleet push is said, never implied; `--no-build` moves the named gerps onto the image already at the top of ECR, or `--no-build --tag vNN` onto that one. No terraform: the runtime tf reads `data.aws_ecr_image` `most_recent`, so a post-deploy plan is ALREADY clean (AgentCore exposes its config, unlike lambda code). No `agent_image_tag` var exists anymore; no fake gateway-target churn — routine image deploys change zero tf
- `bash scripts/docker.sh --build|--run|--stop|--push <ecr-uri>` — the underlying agent-container build/run primitives (`deploy.sh image` drives build/push; use directly for local container smoke)
- `bash scripts/local-dev.sh --install` — once per clone: creates `.venv` (python3.12), installs the tests', the agent container's and the local servers' python deps, and runs `npm ci` in each Node lambda. Every script that runs `.venv/bin/python` (`test.sh`, `deploy.sh`, `investigate.sh`) needs it
- `bash scripts/local-dev.sh --start|--status|--stop` — the local dev stack as plain processes: moto :5000, the owner-app BFF :3000 (sign in at `/dev/login?sub=local-dev`; `/dev` lists the rest), the per_customer stack :8080, the Stripe stand-in :4242, the Cognito stand-in :4243, openlyoperated.biz :3001, and the pump (no port)
- `bash scripts/local-dev.sh --start|--status|--stop` — the local dev stack as plain processes: moto :5000, the owner-app BFF :3000 (sign in at `/dev/login?sub=local-dev`; `/dev` lists the rest), the per_customer stack :8080, the Stripe stand-in :4242, the Cognito stand-in :4243 (it signs every local token), openlyoperated.biz :3001, the pump (no port), the agent :8090 (the container entrypoint on `ANTHROPIC_API_KEY`), the web chat :8082 and the storage portal :8083 (the Node lambdas, `tests/server/node_lambda.mjs`). No AWS credentials anywhere in it
- `bash scripts/e2e.sh [--env local|prod] [--suite smoke|full|lifecycle] [--configure]` — the browser suite (`tests/e2e`); local by default, starting the local stack when a surface is down; `--configure` installs the suite and writes the local seed (`LOCAL_GERPS`, a local owner of gradienterp) into `.env`
- reading the fleet — open alarm tasks, a day's errors across every gerp, the agent's tokens, this month's cost per gerp: the commands are in `prod/platform/operator/AGENTS.md` § reading the fleet
- `python3 tests/server/per_customer/snapshot.py` — re-take the per_customer manifest (routes + every function's env) from the running stack
Expand Down
23 changes: 17 additions & 6 deletions modules/agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Triggers / key outputs:

## layout

- `dev/` — local dev harness (no bedrock, no aws): stdin/stdout chat loop + importlib tool dispatch into sibling lambdas; `run.sh` wires `LOCAL_*` env under `out/agent-dev/`.
- `dev/` — the agent's turn, locally (no bedrock, no aws): `agent.py` (`run_turn`, Claude through the Anthropic SDK) + `tools.py` (the tools discovered from every `schema.json`, each call dispatched to the real handler — a Python one in-process, a Node one through `tests/server/node_lambda.mjs`). The container's `AnthropicEngine`, the local agent process and the tests import them.
- `prompts/` — `bookkeeper.md` (the owner-facing persona; a new gerp's first conversation is the onboarding walk, a guide it retrieves — `onboarding/kb.md`), `broker.md` (the hub), `_shared/integrations.md` (the guides directive — setup AND workflow mechanics — composed into owner-facing modes at boot).
- `docker/` — the AgentCore Runtime container (arm64; FastAPI `entrypoint.py` with the pluggable InferenceEngine + in-process tools; `registries/` bundles field registries for offline validation).
- `lambdas/` — `chat/` (Node web-chat lambda + SPA) and `email/` (SES-inbound Python lambda).
Expand All @@ -97,16 +97,26 @@ Tests live under `tests/agent/local/` and `tests/agent/integ/` — same conventi

## developing locally

No AWS needed. Three surfaces, same anthropic SDK + importlib tool dispatch under the hood.
No AWS needed. Three ways in, the same anthropic SDK + importlib tool dispatch under the hood.

**Interactive dev harness** — stdin/stdout loop, fastest iteration:
**The local stack** — the agent as a process, and the web chat in front of it:

```
export ANTHROPIC_API_KEY=sk-ant-...
bash modules/agent/dev/run.sh
bash scripts/local-dev.sh --start
```

First run creates `.venv/` at the repo root and installs `anthropic` into it; subsequent runs skip bootstrap.
`entrypoint.py` runs under uvicorn on :8090 (`AnthropicEngine`, tools against moto), the chat lambda
on :8082 under `tests/server/node_lambda.mjs` with its SDK pointed at both. Sign in at
`localhost:3000/dev/login?sub=<sub>`, open the gerp, its chat link. Or curl the agent:

```
curl -sS -X POST localhost:8090/invocations -H 'content-type: application/json' \
-H 'x-amzn-bedrock-agentcore-runtime-session-id: dev1' -d '{"prompt": "I sold a latte for $5.50 via Stripe"}'
```

Without the key the stack still starts; a turn answers with the error. `tests/server/AGENTS.md` has
the wiring.

**Scripted smoke catalog**:

Expand Down Expand Up @@ -145,7 +155,8 @@ The **engine owns the agent's authoritative session** — full message history p

Local-only shortcuts:

- **`-v $(pwd):/repo`** — required by `AnthropicEngine`; entrypoint imports `dev/agent.py` and `dev/tools.py` from the mount so the prod image stays small. `StrandsEngine` doesn't need it.
- **`-v $(pwd):/repo`** — what `AnthropicEngine` reads `dev/agent.py` and `dev/tools.py` from inside the container, so the prod image stays small; outside it (the local stack) the entrypoint finds `dev/` beside itself. `StrandsEngine` needs neither.
- **`POST /runtimes/<arn>/invocations`** — the path the SDK sends `InvokeAgentRuntime` to, answered the same as `/invocations`, so the local stack can point the chat lambda's SDK at this process (`AWS_ENDPOINT_URL_BEDROCK_AGENTCORE`).
- **No Bedrock / no real Gateway** during local smoke — the Strands path constructs cleanly but the first MCP `list_tools_sync()` would fail without a deployed Gateway. The local bar is "engine selector flips and construction succeeds."

## deploying
Expand Down
47 changes: 5 additions & 42 deletions modules/agent/dev/agent.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
"""Local dev harness for the gradientERP bookkeeper agent.
"""The agent's turn, locally: Claude through the Anthropic SDK, each tool call dispatched to the
real lambda handler (tools.py) — same data stores, same validation, no AWS.

Runs a stdin/stdout conversation loop backed by Claude via the Anthropic SDK.
Each tool call dispatches to a real accounting lambda handler via importlib —
same data stores, same validation, no AWS. Use this to iterate on the prompt,
tool schemas, and conversation flow before deploying to Bedrock AgentCore.
`run_turn` is what runs it. The container entrypoint's AnthropicEngine calls it for every turn of
the local agent process (`bash scripts/local-dev.sh --start`, :8090), and so do
tests/agent/local/smoke.py and test_bookkeeper_flow.py.

Prerequisites:
export ANTHROPIC_API_KEY=sk-ant-... # the default
# or: AGENT_PROVIDER=bedrock, which uses your AWS creds and production's model id

Usage:
bash modules/agent/dev/run.sh
(handles the .venv bootstrap; then type; Ctrl-D or Ctrl-C to exit)
"""

import json
Expand Down Expand Up @@ -93,35 +88,3 @@ def make_client():
raise SystemExit(f"unknown AGENT_PROVIDER={PROVIDER!r} — expected anthropic | bedrock")
from anthropic import Anthropic
return Anthropic()


def main():
try:
client = make_client()
except ImportError:
sys.stderr.write("requires: pip3 install anthropic (or python3 -m pip install anthropic)\n")
sys.exit(1)
system = load_system_prompt()
messages = []

sys.stderr.write(f"agent ready — {PROVIDER}/{MODEL}, {len(TOOLS)} tools, business={BUSINESS_NAME}\n")
sys.stderr.write("type a message, Ctrl-D to exit\n\n")

while True:
try:
user_input = input("> ")
except (EOFError, KeyboardInterrupt):
print()
return

if not user_input.strip():
continue

messages.append({"role": "user", "content": user_input})
reply = run_turn(client, system, messages)
print(reply)
print()


if __name__ == "__main__":
main()
43 changes: 0 additions & 43 deletions modules/agent/dev/run.sh

This file was deleted.

22 changes: 21 additions & 1 deletion modules/agent/dev/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def _discover():
out = {}
for schema in sorted(REPO_ROOT.glob("modules/*/lambdas/*/schema.json")):
src = schema.parent
if not (src / "main.py").exists():
if not ((src / "main.py").exists() or (src / "index.mjs").exists()):
continue
out[src.name] = (schema, src)
return out
Expand Down Expand Up @@ -95,6 +95,8 @@ def invoke(name, tool_input):
if not entry:
return {"error": f"unknown tool '{name}'", "known": sorted(_CATALOG)[:20]}
_, src = entry
if not (src / "main.py").exists():
return _invoke_node(src.name, tool_input)
from aws import _bundle_dirs
import importlib.util
main = src / "main.py"
Expand All @@ -106,12 +108,30 @@ def invoke(name, tool_input):
module_spec.loader.exec_module(mod)

response = mod.handler(tool_input, None)
return _unwrap(response)


def _unwrap(response):
if isinstance(response, dict) and "body" in response:
body = response["body"]
return json.loads(body) if isinstance(body, str) else body
return response


def _invoke_node(name, tool_input):
"""A Node tool (an `index.mjs`) runs in the local Node process, tests/server/node_lambda.mjs,
started by scripts/local-dev.sh at LOCAL_NODE_URL: the event in, the handler's return out."""
import urllib.request
base = os.environ.get("LOCAL_NODE_URL", "http://127.0.0.1:8082").rstrip("/")
req = urllib.request.Request(f"{base}/invoke/{name}", data=json.dumps(tool_input).encode(),
headers={"content-type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=120) as r:
return _unwrap(json.loads(r.read() or b"null"))
except OSError as e:
return {"error": f"the local Node process at {base} did not answer: {e}"}


if __name__ == "__main__":
print(f"{len(TOOLS)} tools · {_ENV_VARS} env vars")
for t in TOOLS:
Expand Down
36 changes: 31 additions & 5 deletions modules/agent/docker/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,27 +327,45 @@ class AnthropicEngine(InferenceEngine):
"""

def __init__(self) -> None:
dev_path = Path("/repo/modules/agent/dev")
dev_path = Path("/repo/modules/agent/dev") # the container, the repo mounted (scripts/docker.sh --run)
if not dev_path.is_dir():
dev_path = Path(__file__).resolve().parents[1] / "dev" # a process in the repo (scripts/local-dev.sh)
if not dev_path.is_dir():
raise RuntimeError(
f"AnthropicEngine expects the dev harness mounted at {dev_path}. "
f"AnthropicEngine expects the dev harness at {dev_path}. "
"run with `bash scripts/docker.sh --run` (mounts $PWD:/repo) — "
"or set BEDROCK_MODEL_ID + GATEWAY_URL to select StrandsEngine instead."
)
sys.path.insert(0, str(dev_path))
from anthropic import Anthropic
from agent import run_turn as _dev_run_turn # dev/agent.py
self._client = Anthropic()
self._client = None # made on the first turn: the process starts without ANTHROPIC_API_KEY, a turn needs it
self._dev_run_turn = _dev_run_turn
# local smoke has no Strands SessionManager, so the engine keeps its own
# volatile restore store (per-container; matches the dev harness lifetime).
self._store = InMemoryStore()

@staticmethod
def _system(blocks):
"""`_system_blocks` is in Bedrock's shape. The Anthropic API wants `{"type": "text", ...}`
blocks, and a cache point is `cache_control` on the block before it."""
if isinstance(blocks, str):
return blocks
out = []
for b in blocks:
if "text" in b:
out.append({"type": "text", "text": b["text"]})
elif "cachePoint" in b and out:
out[-1]["cache_control"] = {"type": "ephemeral"}
return out

def run_turn(self, system, session_id, user_message):
if self._client is None:
from anthropic import Anthropic
self._client = Anthropic()
prior = self._store.load(session_id)
messages = list(prior)
messages.append({"role": "user", "content": user_message})
reply = self._dev_run_turn(self._client, system, messages)
reply = self._dev_run_turn(self._client, self._system(system), messages)
new_messages = messages[len(prior):]
self._store.append(session_id, new_messages)
return reply, new_messages
Expand Down Expand Up @@ -2613,6 +2631,14 @@ async def invoke(payload: dict, request: Request):
return {"error": f"{type(e).__name__}: {e}", "tb": traceback.format_exc().splitlines()[-10:]}


@app.post("/runtimes/{arn:path}/invocations")
async def invoke_runtime(arn: str, payload: dict, request: Request):
"""The path the SDK sends InvokeAgentRuntime to. The local stack points the web chat lambda's
SDK at this process (AWS_ENDPOINT_URL_BEDROCK_AGENTCORE), so its call lands here unchanged;
the arn and the qualifier are not read."""
return await invoke(payload, request)


async def _invoke_inner(payload: dict, request: Request) -> dict:
# AgentCore HTTP protocol contract: body is {"prompt": "..."}. Local smoke
# uses the same shape for consistency — no dual parsing.
Expand Down
3 changes: 2 additions & 1 deletion modules/agent/lambdas/chat/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ const MEMORY_ACTOR_ID = process.env.MEMORY_ACTOR_ID || "agent-session"; // must
const SESSIONS_BUCKET = process.env.SESSIONS_BUCKET || ""; // S3SessionManager bucket — the agent's authoritative session + checkpoint
const SESSIONS_PREFIX = process.env.SESSIONS_PREFIX || "agent-sessions/"; // must match the container's SESSIONS_PREFIX default
const BUSINESS_NAME = process.env.BUSINESS_NAME || ""; // the gerp's business name — header shows it instead of the login email
const ISSUER = POOL ? `https://cognito-idp.${REGION}.amazonaws.com/${POOL}` : "";
// the pool's issuer; COGNITO_ISSUER names another (the local stand-in, tests/server/cognito)
const ISSUER = process.env.COGNITO_ISSUER || (POOL ? `https://cognito-idp.${REGION}.amazonaws.com/${POOL}` : "");
const CLOCK_LEEWAY = 60;

// invoke_agent_runtime wants the RUNTIME arn + the endpoint name as `qualifier`, not
Expand Down
Loading
Loading