diff --git a/.claude/skills/porting-to-canyonos-core/SKILL.md b/.claude/skills/porting-to-canyonos-core/SKILL.md new file mode 100644 index 0000000..fe6dd49 --- /dev/null +++ b/.claude/skills/porting-to-canyonos-core/SKILL.md @@ -0,0 +1,240 @@ +--- +name: porting-to-canyonos-core +description: Ports existing LangChain, LangGraph, CrewAI, AutoGen, and hand-rolled Python agent projects to CanyonOS Core. Use when converting, migrating, adapting, packaging, building, or deploying an existing agent or multi-agent project onto CanyonOS Core. +compatibility: Requires Python, Docker, and the `ventis` compatibility CLI. Runtime identifiers remain `ventis`, `VENTIS_*`, and `ventis-*`. +--- + +# Port an agent project to CanyonOS Core + +CanyonOS Core is the product name. Its compatibility executable and Python +package remain `ventis`; environment variables and Docker resources retain the +`VENTIS_*` and `ventis-*` prefixes. These are protocol identifiers, not branding +strings. Do not rename them. + +## Load references only when needed + +- Read [references/packaging.md](references/packaging.md) when a source import + does not resolve from `/app`, the source is nested, or packaging metadata is + involved. +- Read [references/llm-proxy.md](references/llm-proxy.md) only when the target + includes `llm_proxy`. +- Read [references/ec2.md](references/ec2.md) only when any config entry uses + `provider: EC2`. +- Read [references/troubleshooting.md](references/troubleshooting.md) after a + failed build, image probe, deploy, or request. +- Read [references/runtime-contract.md](references/runtime-contract.md) when a + validator finding needs explanation or the runtime mechanism is unclear. + +## Goal: thin scaffolding beside untouched source + +```text +agents/.yaml one callable surface per service +agents/.py one thin adapter per service, when needed +workflow/_workflow.py HTTP entry point; calls deploy() +config/global_controller.yaml deployment manifest +config/policy.yaml optional access restriction +pyproject.toml conditional nested-import scaffolding + unchanged +``` + +The file count follows the deployment. A multi-agent port has one yaml/adapter +pair per service that is worth deploying separately. If a source class already +satisfies the runtime contract, point its config entry at that file and do not +copy it into an adapter. + +Everything the source already owns—prompts, tools, schemas, parsing, retries, +model clients, and node bodies—is imported. The port re-expresses only the +CanyonOS Core boundary and framework-owned orchestration. + +The port root is the existing repository root and the directory from which +`ventis build` runs. Write scaffolding there beside existing directories. If the +repository already uses `src/`, leave it in place and put `agents/`, `workflow/`, +and `config/` beside it. Never move or copy the repository into a new `src/` +directory, and never create an outer wrapper merely for the port. + +## 1. Survey before writing + +Identify: + +1. The source entry point and callable input/output. +2. Framework-owned control flow (`StateGraph`, `Crew`, `GroupChat`, routing, + `Send`, `Command`, interrupts). +3. Runtime-injected services nodes read: stores, context, memory, sessions, or + callback managers. +4. Sync versus async boundaries. +5. Imports and declared runtime dependencies. +6. Model provider, credential names, streaming use, and optional `llm_proxy`. +7. Whether independent work fans out and benefits from separate replicas. +8. Whether source imports resolve from the project root that becomes `/app`. + +Run the validator once now. Its header detects capabilities directly from the +importable runtime rather than external development metadata: + +```bash +python /validate.py . +``` + +If config or agent yaml is malformed, the validator defers to `ventis build`. +Capability-gated findings say which runtime behavior is available. + +## 2. Choose service boundaries + +Start with one service. Split only when it creates independent parallel work or +a distinct resource/replica profile. + +- Keep a ReAct loop together; every turn needs shared message history. +- Hoist supervisor task lists and `Send`-style fan-out into the workflow. +- Do not create a one-replica service with no distinct resource profile merely + to mirror every source graph node. + +Rewrite framework-owned edges as ordinary Python. Import the connected node +functions unchanged. Construct runtime-injected service objects from source +configuration; do not invent models, dimensions, stores, or defaults silently. +Report any choice the source does not specify. + +## 3. Write declarations and adapters + +### Agent yaml + +Use one yaml per deployed service. Argument types are bare builtins only: +`str`, `int`, `float`, `bool`, `dict`, or `list`. Every declared argument is +required by the generated stub. `returns.type` is documentation; use `dict` or +`list` to signal that workflow callers must `json.loads` the returned string. + +### Adapter + +The entrypoint exposes a module-level class named exactly `agent.name`. It +constructs with no arguments and its declared methods are synchronous. Read +configuration from the environment in `__init__`. Bridge source coroutines +inside a synchronous method with `asyncio.run(...)`. Serialize framework objects +with their own JSON-safe serializer before returning. + +Do not duplicate source prompts, tools, schemas, or model calls. Keep the source +provider and SDK. + +### Workflow + +Expose `main(query: str)` and call `deploy(main, port=...)` at module scope. +Import generated stubs by yaml basename and agent class name: + +```python +from deploy import deploy +from agents. import +``` + +The deployment platform sends `{query: string}` to `/main`. Pack richer input +inside `query`; any additional workflow parameter has a default. + +Dispatch every remote call before resolving any future: + +```python +futures = [agent.work(item=item) for item in items] +results = [json.loads(future.value()) for future in futures] +``` + +Do not fuse dispatch and `.value()` in one comprehension; that silently +serializes fan-out. Do not add an `if __name__ == "__main__":` block: the +workflow is executed with `__name__ == "__main__"` in production. + +### Config + +For each service, keep these names aligned: + +```text +config entry name == yaml agent.name == entrypoint class name +``` + +Use lowercase `provider: local`; `replicas` is an integer; `requirements` is a +list of distribution-name strings. Put `env_file` at config top level when the +runtime capability is available. Omit `policy.yaml` unless access must be +restricted; if present, give it a non-empty `rules` list. + +## Hard rules + +Capitalized **MUST** and **NEVER** are reserved for port-breaking or +source-integrity rules. The owner column states where each is decided. + +| ID | Rule | Owner | +|---|---|---| +| M1 | Entrypoint MUST define a class named exactly `agent.name` | V006 | +| M2 | The class MUST construct with no arguments | V007 | +| M3 | yaml argument names MUST match Python parameter names | V008 | +| M4 | yaml argument types MUST be bare builtins | V010 | +| M5 | Declared adapter methods MUST be synchronous | V009 | +| M6 | Config names MUST match yaml agent names | build | +| M7 | Config names MUST not collide after lowercase normalization | build output | +| M8 | Local provider MUST be lowercase `local` | deploy preflight | +| M9 | `replicas` MUST be an integer | deploy preflight | +| M10 | `requirements` MUST be a list of strings | build | +| M11 | Workflow MUST expose `main(query)`; extra parameters MUST default | V016 | +| M12 | Workflow MUST NEVER contain a main guard | V017 | +| M13 | Fan-out MUST dispatch all calls before resolving any | V018 | +| M14 | Project modules MUST not take runtime or generated-stub flat names | V019/V020 | +| M15 | Workflow MUST import stubs from `agents.` | V023 | +| M16 | Policy MUST be absent or contain a non-empty `rules` list | deploy preflight | +| M17 | EC2 entries MUST satisfy the EC2 deployment contract | deploy preflight | +| M18 | NEVER copy source prompts, tools, schemas, or model calls | review | +| M19 | NEVER hardcode or bake a real credential into an image | W003 | +| M20 | NEVER edit or vendor the source tree | `git status` | +| M21 | NEVER swap the source LLM provider | review | +| M22 | NEVER silently move, drop, or reclassify source dependencies | review | +| M23 | Framework control flow MUST be rewritten; source node logic MUST be imported | review | +| M24 | A non-resolving source import MUST have usable root packaging metadata when editable install is supported | V031 | + +## 4. Validate, build, and probe + +Run static preflight, then let the build own build-time validation: + +```bash +python /validate.py . +ventis build -c config/global_controller.yaml +``` + +A green build never imports the adapter. Probe each agent image in this order: + +```bash +# Runtime startup path + docker run --rm ventis- \ + python -c "import local_controller" + +# Agent load path; include --env-file when configured + docker run --rm --env-file ventis- \ + python -c "import importlib.util,sys; \ +s=importlib.util.spec_from_file_location('m','.py'); \ +m=importlib.util.module_from_spec(s);sys.modules['m']=m;s.loader.exec_module(m); \ +m.();print('ok')" +``` + +Also probe the workflow image with `python -c "import local_controller"`; it has +its own dependency resolve and generated-stub imports. + +Then deploy, send a representative request, and poll its status: + +```bash +ventis deploy -c config/global_controller.yaml +curl -X POST http://localhost:8080/main \ + -H 'Content-Type: application/json' -d '{"query":""}' +curl http://localhost:8080/status/ +``` + +A successful outer request with a source-level failure still proves the port +reached and returned the source behavior. Record the distinction. + +## 5. Clean up + +After collecting evidence, stop foreground deploy with Ctrl+C and wait for +controller cleanup. Remove exact leftovers if startup crashed. Then remove build +products and exact images from this config: + +```bash +ventis clean +docker image rm ventis- \ + ventis- + +test ! -e stubs && test ! -e grpc_stubs && test ! -e docker_container +docker ps -a --format '{{.Names}}' +``` + +`ventis clean` removes only `stubs/`, `grpc_stubs/`, and `docker_container/`; it +does not remove containers or images. Keep port scaffolding, untouched source, +and requested logs or reports. diff --git a/.claude/skills/porting-to-canyonos-core/references/ec2.md b/.claude/skills/porting-to-canyonos-core/references/ec2.md new file mode 100644 index 0000000..e06daa0 --- /dev/null +++ b/.claude/skills/porting-to-canyonos-core/references/ec2.md @@ -0,0 +1,41 @@ +# EC2 deployment + +Read this only when at least one config entry uses `provider: EC2`. + +## Configuration + +Use `provider: EC2` and declare `instance_type` on every EC2 service entry. The +top-level `ec2` block supplies the runtime's required infrastructure and SSH +settings. Read the target checkout's deploy preflight and EC2 runtime before +writing the block; do not copy values from an example environment. + +Typical required categories are: + +- AMI and instance type +- region and subnet +- security groups +- SSH user and credentials accepted by the runtime + +`ventis deploy` owns basic EC2 config validation. A preflight pass is not proof +that provisioning, SSH, image transfer, or remote container startup works. + +## Networking + +A remote container's `host.docker.internal` names its own EC2 Docker host. It +does not name the local controller machine. Databases, model proxies, and other +services must use addresses reachable from every selected host. + +The environment file may be copied temporarily to a remote host by runtimes that +expose the `env_file` capability. Confirm behavior from the capability probe and +target runtime rather than assuming local Docker semantics. + +## Probes and cleanup + +Run the same runtime and adapter probes against the exact image before remote +deployment. After deploy, verify the remote container logs; controller health +can be green even when agent loading failed. + +Stop foreground deploy normally so the controller can terminate recorded EC2 +instances. If provisioning or startup fails before an instance is recorded, +inspect the cloud provider directly and remove exact leaked resources. Never use +a broad cleanup command against unrelated instances. diff --git a/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md b/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md new file mode 100644 index 0000000..f726bd7 --- /dev/null +++ b/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md @@ -0,0 +1,64 @@ +# LLM proxy integration + +Read this only when the target checkout contains `llm_proxy` or the deployment +explicitly routes model SDKs through it. + +## Preserve provider protocols + +The proxy redirects provider endpoints; it does not convert providers. Keep the +source SDK, model ID, request body, and response parsing unchanged. + +Configure only the provider variables the source uses: + +```dotenv +OPENAI_BASE_URL=http://host.docker.internal:8081/openai/v1 +ANTHROPIC_BASE_URL=http://host.docker.internal:8081/anthropic +AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://host.docker.internal:8081/bedrock +``` + +Some SDKs refuse to initialize without caller credentials. Give agent containers +non-secret placeholders only when required. Keep real OpenAI, Anthropic, or AWS +credentials in the separate proxy process, not in the port's `env_file`. + +## Start locally + +The proxy defaults conflict with a typical deployment: host loopback is not +reachable from a container, and port 8080 is normally used by the workflow API. +Use a non-loopback bind and a different port: + +```bash +PROXY_HOST=0.0.0.0 PROXY_PORT=8081 python -m llm_proxy +curl http://127.0.0.1:8081/healthz +``` + +Local CanyonOS Core containers resolve `host.docker.internal` through their +Docker host mapping. On EC2 that name resolves to each EC2 Docker host, not the +machine running `ventis deploy`. Distributed deployments need a reachable proxy +address or one proxy on each host. + +## Supported call shape + +The implementation buffers complete requests and responses: + +- OpenAI and Anthropic non-streaming HTTP calls are forwarded. +- Bedrock `invoke` is reissued through the proxy's boto3 client. +- OpenAI/Anthropic streaming is unsupported. +- Bedrock `invoke-with-response-stream`, `converse`, and `converse-stream` are + unsupported. + +Survey the source before selecting the proxy. Do not silently disable streaming; +report the unsupported behavior and stop. + +## Credential behavior + +- The OpenAI adapter removes caller authorization and inserts the proxy key. +- The Anthropic adapter removes caller key headers and inserts the proxy key. +- Botocore still signs requests sent to a custom endpoint, so a caller may need + placeholder AWS credentials even though the proxy reissues upstream with its + own identity. +- `/healthz` proves provider registration and Flask availability, not upstream + credential validity. + +OpenAI and Anthropic upstream HTTP errors pass through. Proxy exceptions return +JSON 502 with `error: proxy_error`. Bedrock `ClientError` bodies are reconstructed +with the upstream status and are not byte-for-byte passthrough. diff --git a/.claude/skills/porting-to-canyonos-core/references/packaging.md b/.claude/skills/porting-to-canyonos-core/references/packaging.md new file mode 100644 index 0000000..8520c4a --- /dev/null +++ b/.claude/skills/porting-to-canyonos-core/references/packaging.md @@ -0,0 +1,81 @@ +# Packaging and import roots + +Read this reference when an adapter imports nested source code, the source uses a +`src/` layout, or V031 reports an import-root problem. + +## What `/app` can import + +CanyonOS Core preserves project-relative paths in the image and starts Python at +`/app`. Without an editable install, Python resolves names rooted there: + +- `/app/tools.py` as `import tools` +- `/app/pkg/__init__.py` as `import pkg` +- `/app/src/agents/...` as `import src.agents`, including PEP 420 namespace + directories without `__init__.py` + +It does not resolve `/app/source/pkg` as `import pkg`; `/app/source` must become +an import root first. + +## Detect support, do not infer it from release history + +Run: + +```bash +python /validate.py . +``` + +Read the `editable_install` capability. If it is unavailable and the original +import cannot resolve from `/app`, report a runtime capability blocker and stop. +Do not add a `sys.path` hack or relocate source files. + +## Root metadata is the trigger + +When editable install is supported, only packaging metadata at the **port root** +triggers `pip install -e .`: + +```text +port-root/pyproject.toml detected +port-root/source/pyproject.toml ignored as an install trigger +``` + +A nested source repository may remain untouched. Add minimal root scaffolding +that points package discovery at the existing source package: + +```toml +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "canyonos-port" +version = "0.0.0" +dependencies = [] + +[tool.setuptools.packages.find] +where = ["source/src"] +include = ["pkg*"] +namespaces = true +``` + +Set `where` and `include` from the actual tree and original import spelling. Do +not reference a README or license from this wrapper metadata; file sweeps differ +by runtime capability and a missing referenced file makes the image build fail. + +## Dependencies in nested metadata + +A nested `pyproject.toml` is not installed merely because its Python files are +copied. Keep the source declaration unchanged and repeat its runtime +distributions in each relevant config entry's `requirements` list. This is +compatibility scaffolding, not permission to drop, move, or reclassify declared +dependencies. + +If source metadata is already at the port root, do not create a wrapper. Its +project dependencies participate in the same resolver as config requirements. +Report declared-but-unused toolchain dependencies and their image cost; let the +owner decide whether source metadata should change. + +## Validation boundary + +`ventis build` owns packaging syntax and installation errors. `validate.py` +checks only whether adapter imports appear to require a nested root that the +runtime will not expose. diff --git a/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md b/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md new file mode 100644 index 0000000..94f7401 --- /dev/null +++ b/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md @@ -0,0 +1,187 @@ +# CanyonOS Core runtime contract + +The product is CanyonOS Core. Compatibility identifiers remain `ventis` for the +CLI and Python package, `VENTIS_*` for runtime variables, and `ventis-*` for +Docker resources. + +Read this reference when implementing an adapter or explaining a validator +finding. Runtime-dependent behavior is expressed as capabilities; run +`validate.py` against the target environment instead of inferring support from +release history. + +## Project root and discovery + +`ventis build` uses the current working directory as the project root. + +| Input | Discovery | +|---|---| +| `agents/*.yaml` | direct yaml glob under the project root | +| `config/global_controller.yaml` | default config, overridable with `-c` | +| workflow | `workflow_file` on a `type: workflow` entry | +| policy | `policy.yaml` beside the selected config file | +| generated files | `stubs/`, `grpc_stubs/`, `docker_container/` | + +The config name, yaml `agent.name`, and entrypoint class name form one binding: + +```text +config entry name == yaml agent.name == entrypoint class name +``` + +A missing match may skip an image while the command continues, so inspect build +output and generated image tags. + +## Agent yaml and generated stubs + +The consumed yaml shape is: + +```yaml +agent: + name: ExampleAgent + functions: + - name: work + arguments: + - name: query + type: str + returns: + type: dict +``` + +Argument annotations are generated from bare names without adding imports. Use +builtins. Generated methods have no defaults, so every declared argument is +required at the stub call site. `returns` does not control runtime conversion; +it documents whether workflow code should parse the returned string. + +Stub destinations differ by runtime capability and entrypoint layout. Workflow +code in this port convention imports the generated class from +`agents.`. The validator checks that import against declarations +before the workflow image starts. + +## Agent loading and execution + +The local controller effectively performs: + +```python +module = load(entrypoint) +agent_class = getattr(module, configured_name) +agent = agent_class() +result = getattr(agent, method_name)(**args) +``` + +Consequences: + +- The class is module-level and named exactly as configured. +- Construction takes no arguments. +- Declared methods accept yaml argument names as keyword arguments. +- Methods are synchronous; this path does not await a coroutine. +- Dicts and lists are JSON-encoded before entering Redis; other results become + strings. +- A remote Future's `.value()` returns text, not the original Python object. + +Agent import and construction exceptions are caught by the controller. A failed +agent may still advertise healthy because health is written independently of +successful agent loading. That is why image probes import both the runtime and +the entrypoint explicitly. + +## Workflow execution + +The workflow file is executed, not imported. Therefore: + +- module-level code runs at container startup; +- `__name__ == "__main__"`; +- `deploy()` blocks in the web server; +- the workflow function runs once per request; +- its function name determines the REST route exposed by the compatibility + runtime. + +The deployment platform additionally expects `/main` with a `{query: string}` +body. This platform constraint is stricter than the underlying transport. + +Each stub method returns a Future immediately. `.value()` blocks. Dispatching +and resolving inside one comprehension serializes work without raising an +error; dispatch all calls first, then resolve them. + +The workflow container also starts runtime controller code and has its own +package resolution. Probe it independently from agent images. + +## Build context and collisions + +The runtime copies project files while preserving relative paths, then writes +shared runtime modules, generated stubs, and entrypoints into the image. Later +writes can shadow project files. + +Avoid root project modules named like runtime files, including: + +```text +future.py +ventis_context.py +local_controller.py +local_controller_frontend.py +redis_client.py +grpc_options.py +bedrock.py +deploy.py +session_logging.py +workflow_launcher.py +``` + +Also avoid a yaml basename that shadows a different source module imported by an +adapter. The validator checks deterministic flat-name collisions. + +File sweep and editable-install behavior are runtime capabilities. For nested +imports, follow [packaging.md](packaging.md). + +## Dependencies and protobuf + +Agent and workflow images include a small runtime dependency set. Config +`requirements` adds source-specific distributions. A malformed requirements +value can be normalized away while image generation continues; missing imports +then surface only when the agent loads. + +The build compiles gRPC Python stubs on the host and copies them into images. +The image resolver does not necessarily know the generated-code version. A +source dependency that constrains protobuf below the host generator version can +produce a green image build that dies on: + +```text +import local_controller +``` + +Always run that probe before probing the entrypoint. Treat a generated-code / +runtime-version mismatch as a CanyonOS Core runtime issue, not a reason to alter +source dependencies silently. + +## Credentials capability + +When `env_file` capability is available, the top-level config path is resolved +against the project root and passed at container start. Hidden env files are not +copied into images. Invalid paths are deploy-preflight errors. + +When the capability is unavailable, declaring `env_file` has no effect. If the +source needs credentials, report the capability blocker rather than hardcoding +or vendoring a secret. + +A source that constructs its client at import time works only when credentials +are already in the container environment. Image entrypoint probes therefore use +the same env file as deployment. + +For proxy-specific credential separation, read [llm-proxy.md](llm-proxy.md). + +## Policy and provider behavior + +No policy file means unrestricted service access. If a policy exists, it needs a +non-empty rules list. Rules are evaluated by specificity and first match; +services excluded from the selected rule fail after request acceptance. + +Local provider handling is case-sensitive: use lowercase `local`. EC2 behavior +and remote networking are covered in [ec2.md](ec2.md). + +## Cleanup boundary + +Stopping foreground deploy normally invokes controller cleanup for recorded +containers and Redis. Hard kills and failures before resource registration may +leave resources behind. + +`ventis clean` removes generated `stubs/`, `grpc_stubs/`, and +`docker_container/`. It does not remove containers or images. Remove exact +leftovers explicitly and preserve source, port scaffolding, and requested +evidence. diff --git a/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md b/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md new file mode 100644 index 0000000..361acf9 --- /dev/null +++ b/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md @@ -0,0 +1,60 @@ +# Troubleshooting + +Read this after a failed build, image probe, deploy, or request. For mechanisms, +read [runtime-contract.md](runtime-contract.md). For proxy or remote-host failures, +read [llm-proxy.md](llm-proxy.md) or [ec2.md](ec2.md). + +## Build or deploy stops early + +| Symptom | Likely cause | +|---|---| +| Agent image is missing | Config name matched no yaml name, entrypoint is absent, or build skipped it; inspect build warnings | +| Two services produce one image | Config names collide after lowercase normalization | +| `generated grpc_stubs are missing` | Build did not complete on this host, or generated output was cleaned before deploy | +| `int(... NoneType)` during local deploy | Local provider was not written exactly as lowercase `local` | +| Replica conversion `TypeError` | `replicas` is not an integer | +| Policy `AttributeError` | Policy exists but is empty or has null/non-list rules; remove it when unrestricted | +| Port or container name already in use | A previous deployment did not complete cleanup | + +## Container exits or serves nothing + +| Symptom | Likely cause | +|---|---| +| `import local_controller` raises protobuf version error | Host-generated gRPC code is newer than the image's protobuf runtime | +| First request says `No agent loaded` | Entrypoint import, class lookup, or constructor failed and the controller swallowed the exception; inspect container logs | +| Replica is healthy but serves nothing | Health publication does not prove successful agent loading | +| Missing credentials while loading | Env injection is unavailable/misconfigured or the source reads another variable | +| Source module is missing | Original import does not resolve from `/app`; read [packaging.md](packaging.md) | +| Third-party module is missing | Distribution is absent from source metadata and config requirements | +| Stub import raises `NameError` | yaml argument type is not a bare builtin | +| Source module behaves like an empty stub | A generated stub basename shadowed the source module | +| Runtime-named project module disappears | Shared runtime copy overwrote a root project module with the same name | + +## Request is accepted, then fails + +| Symptom | Likely cause | +|---|---| +| Unexpected keyword argument | yaml argument name differs from adapter parameter name | +| Required argument missing | yaml does not declare a required adapter parameter, or workflow platform sent only `query` | +| Unauthorized service | The first matching policy rule excludes that service | +| `.value()` returns dict-like text | Expected; remote values are strings, so use `json.loads` | +| Object is not JSON serializable | Adapter returned framework objects without its JSON-safe serializer | +| Redis contains a coroutine repr | Adapter method is async and the controller did not await it | +| Fan-out is no faster | Dispatch and `.value()` were fused, serializing the calls | +| Debug block runs at startup | Workflow is executed with `__name__ == "__main__"` | + +## Deployment platform endpoint + +| Symptom | Likely cause | +|---|---| +| 404 while workflow container is healthy | Workflow function is not named `main` | +| 400 before host receives request | Body is not the platform's `{query: string}` shape | +| Extra workflow argument is missing | Platform sends only `query`; extra parameters need defaults | + +## Cleanup + +| Symptom | Likely cause | +|---|---| +| `ventis clean` succeeds but containers remain | The command removes generated directories only | +| `ventis clean` succeeds but images remain | Image deletion is separate and requires exact tags | +| Next deployment collides with old resources | Foreground deploy was killed or crashed before controller cleanup | diff --git a/.claude/skills/porting-to-canyonos-core/validate.py b/.claude/skills/porting-to-canyonos-core/validate.py new file mode 100755 index 0000000..04baf37 --- /dev/null +++ b/.claude/skills/porting-to-canyonos-core/validate.py @@ -0,0 +1,1257 @@ +#!/usr/bin/env python3 +"""Preflight the runtime traps that `ventis build` cannot see. + +This deliberately does not duplicate build-time validation such as malformed +YAML, missing entrypoints, or config-to-yaml matching. `ventis build` owns those +checks. This script parses Python without importing it and catches failures that +otherwise stay hidden until a container loads an agent, starts a workflow, or +serves its first request. A replica is not evidence: the controller writes +`healthy` to Redis before `_load_agent` runs. + + python validate.py [project_dir] [-c config/global_controller.yaml] + [--json] [--strict] + +Exit 1 if any ERROR was reported, 0 otherwise. --strict also fails on warnings. + +Runtime capabilities vary across CanyonOS Core installations. This script probes +the importable `ventis` package directly. A capability-gated check reports +UNAVAILABLE when its behavior cannot be proven. +""" + +import argparse +import ast +import builtins +import json +import os +import re +import sys +from typing import ClassVar + +try: + import yaml +except ImportError: # pragma: no cover - pyyaml is a CanyonOS Core dependency + sys.stderr.write("validate.py needs pyyaml: pip install pyyaml\n") + raise SystemExit(2) from None + + +DEFAULT_CONFIG_PATH = "config/global_controller.yaml" + +# Copied flat into every image over the swept project tree, so a project module +# landing flat under one of these names is overwritten. +# ventis/stub_generator.py generate_docker / generate_workflow_docker. +RUNTIME_FLAT_NAMES = frozenset( + { + "future.py", + "ventis_context.py", + "local_controller.py", + "local_controller_frontend.py", + "redis_client.py", + "grpc_options.py", + "gpu_metrics.py", + "bedrock.py", + "deploy.py", + "session_logging.py", + "workflow_launcher.py", + } +) + +# ventis/stub_generator.py BASE_AGENT_REQUIREMENTS / BASE_WORKFLOW_REQUIREMENTS. +BASE_AGENT_REQUIREMENTS = [ + "grpcio", + "grpcio-tools", + "redis", + "pyyaml", + "psutil", + "ipdb", + "ipython", + "boto3", +] +# Import name -> distribution name, for the handful where they differ and the +# mismatch would otherwise be reported as a missing requirement. +IMPORT_TO_DISTRIBUTION = { + "attr": "attrs", + "bs4": "beautifulsoup4", + "cv2": "opencv-python", + "dateutil": "python-dateutil", + "dotenv": "python-dotenv", + "grpc": "grpcio", + "grpc_tools": "grpcio-tools", + "jwt": "pyjwt", + "PIL": "pillow", + "psycopg": "psycopg", + "psycopg2": "psycopg2-binary", + "pydantic_settings": "pydantic-settings", + "sklearn": "scikit-learn", + "typing_extensions": "typing-extensions", + "yaml": "pyyaml", +} + +SECRET_PATTERNS = [ + (re.compile(r"sk-[A-Za-z0-9_-]{20,}"), "an OpenAI-style secret key"), + (re.compile(r"AKIA[0-9A-Z]{16}"), "an AWS access key id"), + (re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), "a GitHub token"), + (re.compile(r"AIza[0-9A-Za-z_-]{30,}"), "a Google API key"), +] +SECRET_NAME = re.compile(r"(API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)", re.IGNORECASE) + +ERROR = "ERROR" +WARN = "WARN" +INFO = "INFO" + + +# ------------------------------------------------------------------ # +# Capabilities # +# ------------------------------------------------------------------ # +# +# Stable labels for behavior detected from the importable runtime. They contain +# no external development metadata. + +CAPABILITY_SOURCE = { + "env_file": "runtime env-file injection", + "editable_install": "editable project installation", + "sweeps_all_files": "full project-file sweep", + "stub_two_destinations": "flat and package stub destinations", +} + + +def probe_capabilities(): + """Ask the importable ventis package what it actually supports.""" + caps = dict.fromkeys(CAPABILITY_SOURCE, False) + caps["ventis"] = False + try: + from ventis import stub_generator + except Exception: # noqa: BLE001 - a broken install must not crash the check + return caps + + caps["ventis"] = True + caps["editable_install"] = hasattr(stub_generator, "_install_step") + caps["sweeps_all_files"] = hasattr(stub_generator, "_sweep_project_files") + caps["stub_two_destinations"] = hasattr(stub_generator, "_stub_destinations") + + import importlib + + for module_name in ("ventis.controller.utils.env_file", "ventis.utils.env_file"): + try: + module = importlib.import_module(module_name) + except Exception: # noqa: BLE001,S112 - the other path is the live one + continue + if hasattr(module, "resolve_env_file"): + caps["env_file"] = True + break + return caps + + +# ------------------------------------------------------------------ # +# YAML with line numbers # +# ------------------------------------------------------------------ # + + +class LineDict(dict): + """A mapping that remembers where it and each of its keys were written.""" + + line = 0 + key_lines: ClassVar[dict] = {} + + +class LineLoader(yaml.SafeLoader): + pass + + +def _construct_mapping(loader, node): + data = LineDict() + yield data + data.update(loader.construct_mapping(node, deep=False)) + data.line = node.start_mark.line + 1 + data.key_lines = { + key.value: key.start_mark.line + 1 + for key, _ in node.value + if isinstance(key, yaml.ScalarNode) + } + + +LineLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping +) + + +def line_of(mapping, key=None): + """The source line of `key` inside `mapping`, or of the mapping itself.""" + if not isinstance(mapping, LineDict): + return 0 + if key is not None: + return mapping.key_lines.get(key, mapping.line) + return mapping.line + + +def load_yaml(path): + """Parse `path`, returning (data, error). Never raises.""" + try: + with open(path, "r", encoding="utf-8") as handle: + return yaml.load(handle, Loader=LineLoader), None + except Exception as exc: # noqa: BLE001 - any parse failure is a finding + return None, str(exc) + + +# ------------------------------------------------------------------ # +# Findings # +# ------------------------------------------------------------------ # + + +class Report: + def __init__(self, project_dir, capabilities): + self.project_dir = project_dir + self.capabilities = capabilities + self.findings = [] + # A peer agent is imported by the name of its generated stub, which the + # build copies flat into every image. Those are not project modules and + # need no requirement. + self.stub_module_names = set() + + def add(self, check, level, path, line, summary, mechanism): + self.findings.append( + { + "check": check, + "level": level, + "path": self.rel(path) if path else "", + "line": line or 0, + "summary": summary, + "mechanism": mechanism, + } + ) + + def error(self, check, path, line, summary, mechanism): + self.add(check, ERROR, path, line, summary, mechanism) + + def warn(self, check, path, line, summary, mechanism): + self.add(check, WARN, path, line, summary, mechanism) + + def unavailable(self, check, summary): + self.add(check, INFO, "", 0, summary, "") + + def rel(self, path): + try: + return os.path.relpath(path, self.project_dir) + except ValueError: + return path + + def counts(self): + errors = sum(1 for f in self.findings if f["level"] == ERROR) + warnings = sum(1 for f in self.findings if f["level"] == WARN) + return errors, warnings + + +# ------------------------------------------------------------------ # +# Python source helpers # +# ------------------------------------------------------------------ # + + +def parse_python(path): + """AST for `path`, or (None, error). The port is never imported.""" + try: + with open(path, "r", encoding="utf-8") as handle: + source = handle.read() + except OSError as exc: + return None, str(exc) + try: + return ast.parse(source, filename=path), None + except SyntaxError as exc: + return None, f"{exc.msg} (line {exc.lineno})" + + +def find_class(tree, name): + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == name: + return node + return None + + +def class_methods(class_node): + return { + node.name: node + for node in class_node.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + +def parameter_names(func_node): + """Every parameter a caller can pass by keyword, minus self.""" + args = func_node.args + positional = [a.arg for a in args.posonlyargs + args.args] + if positional and positional[0] in ("self", "cls"): + positional = positional[1:] + return positional + [a.arg for a in args.kwonlyargs] + + +def required_parameters(func_node): + """Parameters with no default, minus self.""" + args = func_node.args + positional = [a.arg for a in args.posonlyargs + args.args] + if positional and positional[0] in ("self", "cls"): + positional = positional[1:] + if args.defaults: + positional = positional[: len(positional) - len(args.defaults)] + kwonly = [ + arg.arg + for arg, default in zip(args.kwonlyargs, args.kw_defaults) + if default is None + ] + return positional + kwonly + + +def toplevel_import_names(tree): + """Top-level package name of every import in the module, with line numbers.""" + names = {} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + names.setdefault(alias.name.split(".")[0], node.lineno) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + names.setdefault(node.module.split(".")[0], node.lineno) + return names + + +# ------------------------------------------------------------------ # +# V006-V010 adapter failures hidden by _load_agent # +# ------------------------------------------------------------------ # + +BUILTIN_TYPE_NAMES = frozenset( + name + for name in dir(builtins) + if isinstance(getattr(builtins, name), type) and not name[0].isupper() +) + + +def check_adapter(report, agent_yaml_path, agent_block, entry, project_dir): + """V006 V007 V008 V009 V010.""" + name = agent_block["name"] + functions = agent_block.get("functions") or [] + check_argument_types(report, agent_yaml_path, functions) + + entrypoint = entry.get("entrypoint") + if not entrypoint: + return + entrypoint_path = os.path.join(project_dir, entrypoint) + if not os.path.isfile(entrypoint_path): + return + + tree, error = parse_python(entrypoint_path) + if tree is None: + report.error( + "V006", + entrypoint_path, + 0, + f"the entrypoint does not parse: {error}", + "_load_agent exec_module's it and swallows the exception; the first " + "request answers 'No agent loaded'.", + ) + return + + class_node = find_class(tree, name) + if class_node is None: + classes = [n.name for n in tree.body if isinstance(n, ast.ClassDef)] + found = ", ".join(classes) if classes else "no classes at all" + report.error( + "V006", + entrypoint_path, + 1, + f"no class named `{name}` at module level (found: {found})", + "_load_agent does getattr(module, VENTIS_AGENT_NAME) and swallows " + "the AttributeError. The class name must equal agent.name exactly.", + ) + return + + methods = class_methods(class_node) + check_constructor(report, entrypoint_path, name, methods) + + for func in functions: + if not isinstance(func, dict) or not isinstance(func.get("name"), str): + continue + check_method(report, entrypoint_path, agent_yaml_path, name, func, methods) + + +def check_argument_types(report, agent_yaml_path, functions): + """V010 -- the type string is pasted into an ast.Name, never checked.""" + for func in functions: + if not isinstance(func, dict): + continue + for arg in func.get("arguments") or []: + if not isinstance(arg, dict) or "type" not in arg: + continue + declared = arg.get("type") + if not isinstance(declared, str): + continue # stub generation reports malformed type values + if declared in BUILTIN_TYPE_NAMES: + continue + report.error( + "V010", + agent_yaml_path, + line_of(arg, "type"), + f"`type: {declared}` is not a builtin", + "stub_generator pastes it verbatim into the generated " + "annotation, and the stub module imports only Future and " + "inspect. Anything else raises NameError when the stub is " + "imported -- after a green build. Use str int float bool dict " + "list.", + ) + + +def check_constructor(report, entrypoint_path, name, methods): + """V007 -- _load_agent calls agent_class() with no arguments.""" + init = methods.get("__init__") + if init is None: + return + required = required_parameters(init) + if required: + report.error( + "V007", + entrypoint_path, + init.lineno, + f"`{name}.__init__` requires {', '.join(required)}", + "_load_agent calls agent_class() with no arguments; the TypeError is " + "swallowed and the first request answers 'No agent loaded'. Read " + "configuration from the environment inside __init__ instead.", + ) + + +def check_method(report, entrypoint_path, agent_yaml_path, class_name, func, methods): + """V008 V009.""" + func_name = func["name"] + method = methods.get(func_name) + if method is None: + report.error( + "V008", + entrypoint_path, + 0, + f"`{class_name}` has no method `{func_name}`", + "The yaml declares it, so callers get a stub for it; the controller " + f"then answers \"Agent {class_name} has no method '{func_name}'\".", + ) + return + + # V009 -- nothing on the execution path awaits. + if isinstance(method, ast.AsyncFunctionDef): + report.error( + "V009", + entrypoint_path, + method.lineno, + f"`{class_name}.{func_name}` is `async def`", + "The executor calls method(**args) with no await, so Redis receives " + "''. Keep the signature synchronous and call " + "asyncio.run(...) inside the body.", + ) + + # V008 -- the controller calls method(**args) with the yaml's names. + declared = [ + arg["name"] + for arg in func.get("arguments") or [] + if isinstance(arg, dict) and isinstance(arg.get("name"), str) + ] + actual = parameter_names(method) + required = required_parameters(method) + + missing = [arg for arg in declared if arg not in actual] + if missing: + report.error( + "V008", + entrypoint_path, + method.lineno, + f"`{class_name}.{func_name}` has no parameter " + f"{', '.join(repr(m) for m in missing)}, but the yaml declares it", + "LocalController does method(**args) with the yaml's argument names. " + "A mismatch is TypeError: unexpected keyword argument, at request " + f"time. See {os.path.basename(agent_yaml_path)}.", + ) + + unfilled = [arg for arg in required if arg not in declared] + if unfilled: + report.error( + "V008", + entrypoint_path, + method.lineno, + f"`{class_name}.{func_name}` requires {', '.join(unfilled)}, which " + "the yaml does not declare", + "Only declared arguments are ever sent, and the generated stub gives " + "none of them a default. Declare them in the yaml or default them " + "in the signature.", + ) + + + +# ------------------------------------------------------------------ # +# V016-V018 the workflow # +# ------------------------------------------------------------------ # + + +def check_stub_imports(report, workflow_path, tree, stub_classes): + """V023 -- the workflow must import a stub as `from agents. import `. + + The build copies each stub to exactly one path, and for the workflow image + that path is agents/.py. Two ways of writing this line fail, and + the project walks you into both: the flat form is what examples/ uses, and + the class name is the one `ventis build` prints, which is not the one it + writes. + """ + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + base = alias.name.split(".")[0] + if base in stub_classes: + report.error( + "V023", workflow_path, node.lineno, + f"`import {alias.name}` -- the stub is at " + f"agents/{base}.py, not flat", + "The build copies a stub to one path, and for the " + "workflow that path is under agents/. This is a " + "ModuleNotFoundError the moment the workflow runs. " + f"Write `from agents.{base} import {stub_classes[base]}`.", + ) + continue + + if not isinstance(node, ast.ImportFrom) or not node.module: + continue + + module = node.module + if module in stub_classes: + report.error( + "V023", workflow_path, node.lineno, + f"`from {module} import ...` -- the stub is at " + f"agents/{module}.py, not flat", + "The build copies a stub to one path, and for the workflow " + "that path is under agents/. The flat form is what this " + "repository's own examples use and it raises " + "ModuleNotFoundError in the workflow image. Write " + f"`from agents.{module} import {stub_classes[module]}`.", + ) + continue + + if not module.startswith("agents."): + continue + base = module.split(".", 1)[1] + expected = stub_classes.get(base) + if expected is None: + continue + for alias in node.names: + if alias.name == expected: + continue + if alias.name == f"{expected}Stub": + report.error( + "V023", workflow_path, node.lineno, + f"`{alias.name}` is the name the build prints, not the " + f"class it writes", + "generate_agent_stub sets class_name = agent_config['name'] " + "and then recomputes it with a 'Stub' suffix for the log " + "line only. The message names a class that does not exist; " + f"the class is `{expected}`.", + ) + else: + report.error( + "V023", workflow_path, node.lineno, + f"`{alias.name}` is not a class the stub for {base} defines", + f"The stub's class carries the agent's own name: `{expected}`.", + ) + + +def check_workflow(report, workflow_path, stub_classes=None): + """V016 V017 V018 V023.""" + tree, error = parse_python(workflow_path) + if tree is None: + report.error("V016", workflow_path, 0, f"does not parse: {error}", "") + return + + main = None + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and ( + node.name == "main" + ): + main = node + break + + if main is None: + defined = [ + n.name + for n in tree.body + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) + ] + found = ", ".join(defined) if defined else "no top-level functions" + report.error( + "V016", + workflow_path, + 1, + f"no top-level function named `main` (found: {found})", + "CanyonOS Core serves POST /, but the deployment platform's " + "test endpoint posts to a hardcoded /main. A differently named " + "workflow builds, deploys and stays unreachable -- 404, container " + "healthy.", + ) + else: + check_main_signature(report, workflow_path, main) + + if stub_classes: + check_stub_imports(report, workflow_path, tree, stub_classes) + + # V016 -- deploy() is what starts Flask. + if not any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "deploy" + for node in ast.walk(tree) + ): + report.error( + "V016", + workflow_path, + 1, + "the workflow never calls `deploy(...)`", + "workflow_launcher.py exec's this file and nothing else starts the " + "HTTP server; the container comes up serving nothing.", + ) + + check_main_guard(report, workflow_path, tree) + check_fused_fanout(report, workflow_path, tree) + + +def check_main_signature(report, workflow_path, main): + """V016 -- the platform sends exactly {"query": ...}.""" + if isinstance(main, ast.AsyncFunctionDef): + report.error( + "V016", + workflow_path, + main.lineno, + "`main` is `async def`", + "deploy() calls workflow_fn(**kwargs) on a Flask worker thread with " + "no await; the response body would be a coroutine repr.", + ) + params = parameter_names(main) + if not params: + report.error( + "V016", + workflow_path, + main.lineno, + "`main` takes no arguments", + 'The platform posts {"query": "..."} and deploy() splats the ' + "body in as kwargs -- TypeError on every request.", + ) + return + if params[0] != "query": + report.error( + "V016", + workflow_path, + main.lineno, + f"`main`'s first parameter is `{params[0]}`, not `query`", + "The platform's body schema is strictly validated as " + "{query: string}; any other key is rejected with 400 in the control " + "plane, before the request reaches the host.", + ) + extra = [p for p in required_parameters(main) if p != "query"] + if extra: + report.error( + "V016", + workflow_path, + main.lineno, + f"`main` requires {', '.join(extra)} beyond `query`", + "Only `query` is ever sent, so every other parameter needs a " + "default or the call raises on every request. Pack richer input " + "into `query`.", + ) + + +def check_main_guard(report, workflow_path, tree): + """V017 -- the workflow is exec'd, so __name__ == "__main__".""" + for node in tree.body: + if not isinstance(node, ast.If): + continue + test = node.test + if ( + isinstance(test, ast.Compare) + and isinstance(test.left, ast.Name) + and test.left.id == "__name__" + and any( + isinstance(c, ast.Constant) and c.value == "__main__" + for c in test.comparators + ) + ): + report.error( + "V017", + workflow_path, + node.lineno, + '`if __name__ == "__main__":` block in the workflow', + "workflow_launcher.py runs exec(open().read()), so " + "__name__ IS '__main__' here and this block executes in " + "production, at container start.", + ) + + +def check_fused_fanout(report, workflow_path, tree): + """V018 -- .value() blocks, so dispatching and resolving in one + comprehension runs the fan-out one call at a time.""" + comprehensions = (ast.ListComp, ast.SetComp, ast.GeneratorExp) + for node in ast.walk(tree): + if not isinstance(node, comprehensions + (ast.DictComp,)): + continue + elements = ( + [node.key, node.value] if isinstance(node, ast.DictComp) else [node.elt] + ) + for element in elements: + for inner in ast.walk(element): + if ( + isinstance(inner, ast.Call) + and isinstance(inner.func, ast.Attribute) + and inner.func.attr == "value" + and isinstance(inner.func.value, ast.Call) + ): + report.error( + "V018", + workflow_path, + node.lineno, + "one comprehension both dispatches a call and resolves " + "it with .value()", + ".value() blocks, so each call completes before the " + "next is dispatched. It does not error -- the fan-out " + "is just silently serial, and with it the reason to be " + "on CanyonOS Core. Dispatch every call first, then resolve: " + "futures = [a.work(i) for i in items] then " + "[f.value() for f in futures].", + ) + return + + +# ------------------------------------------------------------------ # +# V019-V020 what the copy order overwrites # +# ------------------------------------------------------------------ # + + +def check_flat_collisions(report, project_dir, yaml_paths, entrypoints): + """V019 V020 -- later copies land on earlier ones at the context root.""" + for entry in sorted(os.listdir(project_dir)): + path = os.path.join(project_dir, entry) + if not os.path.isfile(path) or not entry.endswith(".py"): + continue + + # V019 -- the shared runtime is copied flat, after the project sweep. + if entry in RUNTIME_FLAT_NAMES: + report.error( + "V019", + path, + 1, + f"a project module named `{entry}` sits at the project root", + "The shared CanyonOS Core runtime is copied flat into the image after " + "the project sweep, so this file is overwritten by CanyonOS Core's own " + f"{entry}. Rename it or move it into a package directory.", + ) + + # V020 -- a stub is copied flat under its yaml's basename. + entrypoint_basenames = {os.path.basename(e) for e in entrypoints if e} + for yaml_path in yaml_paths: + stem = os.path.splitext(os.path.basename(yaml_path))[0] + module = f"{stem}.py" + if module in entrypoint_basenames: + continue # the entrypoint is copied last and wins its flat name back + candidate = os.path.join(project_dir, module) + if os.path.isfile(candidate): + report.error( + "V020", + candidate, + 1, + f"`{os.path.basename(yaml_path)}` generates a stub that lands on " + f"`{module}`", + "The yaml's basename names the stub, and the stub is copied flat " + "over the swept tree. Anything importing this module inside the " + "container gets the generated stub instead of the real code. " + "Rename the yaml to match its own entrypoint.", + ) + + +# ------------------------------------------------------------------ # +# V030-V031 capability-gated rules # +# ------------------------------------------------------------------ # + + +def check_env_file(report, config, config_path, project_dir): + """V030 -- gated on detected env-file injection support.""" + declared = config.get("env_file") + supported = report.capabilities.get("env_file") + + if not supported: + if declared: + report.error( + "V030", + config_path, + line_of(config, "env_file"), + f"`env_file: {declared}` is set, but this CanyonOS Core never reads it", + "No resolve_env_file in the importable ventis package, so the " + "key is silently dropped and the container answers a provider " + "credential error on the first request. This port requires the " + "`env_file` runtime capability.", + ) + else: + report.unavailable( + "V030", + "env_file is not supported by the importable `ventis` runtime. " + "Credentials have no declared path into a container on this tree.", + ) + return + + if not declared: + report.warn( + "V030", + config_path, + line_of(config), + "no `env_file:` in the config", + "Only runtime-managed VENTIS_* variables are guaranteed without it. " + "If the source reads credentials from the environment, the first " + "request fails on a provider error.", + ) + return + + # Path existence and readability are deploy-preflight checks. Do not + # duplicate them here. + + +def check_import_root(report, project_dir, entrypoint_paths): + """V031 -- gated on detected editable-install support.""" + supported = report.capabilities.get("editable_install") + has_metadata = any( + os.path.isfile(os.path.join(project_dir, name)) + for name in ("pyproject.toml", "setup.py", "setup.cfg") + ) + + non_flat = [] + for path in entrypoint_paths: + tree, _ = parse_python(path) + if tree is None: + continue + for name, lineno in toplevel_import_names(tree).items(): + if name in report.stub_module_names or f"{name}.py" in RUNTIME_FLAT_NAMES: + continue + if _resolves_flat(project_dir, name): + continue + location = _resolves_nested(project_dir, name) + if location: + non_flat.append((path, lineno, name, location)) + + if not supported: + report.unavailable( + "V031", + "the editable install (`-e .`) is not supported by the importable " + "`ventis` runtime. Only names rooted at /app import inside a container.", + ) + for path, lineno, name, location in non_flat: + report.error( + "V031", + path, + lineno, + f"`import {name}` resolves to {location}, which is not at the " + "project root", + "sys.path[0] is /app and this CanyonOS Core runs no editable install, " + "so only modules swept to the root import. The adapter raises " + "ModuleNotFoundError inside _load_agent and the first request " + "answers 'No agent loaded'.", + ) + return + + if non_flat and not has_metadata: + for path, lineno, name, location in non_flat: + report.error( + "V031", + path, + lineno, + f"`import {name}` resolves to {location}, and the project root " + "has no packaging metadata", + "A pyproject.toml, setup.py or setup.cfg at the port root is " + "what adds `-e .`; metadata nested inside the untouched source " + "tree is ignored. Add minimal root metadata pointing at the " + "existing package directory. Without it the install is skipped " + "silently.", + ) + + +def _resolves_flat(project_dir, name): + """Whether Python can resolve `name` with /app as its import root. + + A directory does not need __init__.py: PEP 420 namespace packages resolve + from sys.path just like regular packages. + """ + return os.path.isfile(os.path.join(project_dir, f"{name}.py")) or os.path.isdir( + os.path.join(project_dir, name) + ) + + +def _resolves_nested(project_dir, name): + """Where below /app `name` lives but cannot resolve as a top-level name.""" + for root, dirs, files in os.walk(project_dir): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + if root == project_dir: + continue + if f"{name}.py" in files: + return os.path.relpath(os.path.join(root, f"{name}.py"), project_dir) + if name in dirs: + return os.path.relpath(os.path.join(root, name), project_dir) + return None + + +# ------------------------------------------------------------------ # +# W003, W006 secrets and imports a green build does not reject # +# ------------------------------------------------------------------ # + + +def check_secrets(report, port_paths): + """W003 -- env_file is the way in; nothing else is.""" + for path in port_paths: + try: + with open(path, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines() + except OSError: + continue + for number, line in enumerate(lines, start=1): + for pattern, description in SECRET_PATTERNS: + if pattern.search(line): + report.warn( + "W003", + path, + number, + f"this line looks like {description}", + "Never put a secret in the source tree or the build " + "context. The build sweeps the project into every " + "image.", + ) + break + + tree, _ = parse_python(path) + if tree is None: + continue + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + if not ( + isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + and node.value.value.strip() + ): + continue + for target in node.targets: + if isinstance(target, ast.Name) and SECRET_NAME.search(target.id): + report.warn( + "W003", + path, + node.lineno, + f"`{target.id}` is assigned a literal string", + "Read it from the environment instead; the build sweeps " + "this file into every image.", + ) + + +def _pyproject_dependencies(project_dir): + """What `-e .` installs alongside `requirements:`, or None if unreadable. + + None and the empty set mean different things here: empty means the project + declares no dependencies, None means we could not find out -- a setup.py, or + a tomllib this interpreter does not have. The caller must not treat the + second as the first, or it warns about imports the install would satisfy. + """ + path = os.path.join(project_dir, "pyproject.toml") + if not os.path.isfile(path): + return None + try: + import tomllib + except ImportError: # < 3.11 + return None + try: + with open(path, "rb") as handle: + data = tomllib.load(handle) + except Exception: # noqa: BLE001 - malformed metadata is uv's error to give + return None + deps = (data.get("project") or {}).get("dependencies") + if not isinstance(deps, list): + return None + return {_normalize_distribution(d) for d in deps if isinstance(d, str)} + + +def check_requirements_coverage( + report, project_dir, entry, entrypoint_path, config_path +): + """W006 -- an import the container cannot satisfy.""" + tree, _ = parse_python(entrypoint_path) + if tree is None: + return + + declared = { + _normalize_distribution(item) + for item in (entry.get("requirements") or []) + if isinstance(item, str) + } + + # Where the editable install exists, `-e .` resolves the project's own + # [project.dependencies] in the same pass as `requirements:`. Warning about + # those is a false positive, and a false warning about a dependency is worse + # than none: it teaches the reader to dismiss this check. + editable = report.capabilities.get("editable_install") + metadata = any( + os.path.isfile(os.path.join(project_dir, name)) + for name in ("pyproject.toml", "setup.py", "setup.cfg") + ) + unreadable_metadata = False + if editable and metadata: + project_deps = _pyproject_dependencies(project_dir) + if project_deps is None: + unreadable_metadata = True + else: + declared |= project_deps + base = {_normalize_distribution(item) for item in BASE_AGENT_REQUIREMENTS} + stdlib = getattr(sys, "stdlib_module_names", frozenset()) + + for name, lineno in sorted(toplevel_import_names(tree).items()): + if name in stdlib or name == "ventis": + continue + # Provided by the image itself: the shared runtime is copied flat, and + # every agents/*.yaml generates a stub that is copied flat too. + if f"{name}.py" in RUNTIME_FLAT_NAMES or name in report.stub_module_names: + continue + if _resolves_flat(project_dir, name) or _resolves_nested(project_dir, name): + continue + distribution = _normalize_distribution(IMPORT_TO_DISTRIBUTION.get(name, name)) + if distribution in base or distribution in declared: + continue + if unreadable_metadata: + mechanism = ( + "The container installs the base list, `requirements:`, and -- " + "since this project declares packaging metadata -- whatever " + "`-e .` resolves from it. That metadata could not be read here, " + f"so if it already requires `{name}` this line is noise; " + "otherwise it is a ModuleNotFoundError inside _load_agent and " + "'No agent loaded' on the first request." + ) + else: + mechanism = ( + "The container installs the base list plus `requirements:` and " + "nothing else, so this is a ModuleNotFoundError inside " + "_load_agent and 'No agent loaded' on the first request. If the " + f"distribution is named something other than `{name}`, declare " + f"that name in {report.rel(config_path)}." + ) + report.warn( + "W006", + entrypoint_path, + lineno, + f"`import {name}` is in neither the runtime's base list nor this " + "entry's `requirements:`", + mechanism, + ) + + +def _normalize_distribution(name): + return re.split(r"[<>=!\[;\s]", name.strip().lower(), maxsplit=1)[0].replace( + "_", "-" + ) + + +# ------------------------------------------------------------------ # +# Driver # +# ------------------------------------------------------------------ # + + +def validate(project_dir, config_path, capabilities): + """Inspect only failures hidden behind a successful image build.""" + report = Report(project_dir, capabilities) + + # The build owns config/YAML syntax and shape validation. We read only enough + # valid structure to locate code for the deeper checks below. + config, error = load_yaml(config_path) + if error is not None or not isinstance(config, dict): + report.unavailable( + "BUILD", + "runtime preflight skipped because the config cannot be read; " + "ventis build owns and reports this error.", + ) + return report + + import glob + + yaml_paths = sorted(glob.glob(os.path.join(project_dir, "agents", "*.yaml"))) + report.stub_module_names = { + os.path.splitext(os.path.basename(path))[0] for path in yaml_paths + } + + agents_by_name = {} + stub_classes = {} + for path in yaml_paths: + data, yaml_error = load_yaml(path) + agent = data.get("agent") if isinstance(data, dict) else None + name = agent.get("name") if isinstance(agent, dict) else None + if yaml_error is not None or not isinstance(name, str): + continue # ventis build reports malformed agent declarations + agents_by_name[name] = (path, agent) + stub_classes[os.path.splitext(os.path.basename(path))[0]] = name + + entries = config.get("agents") + if not isinstance(entries, list): + report.unavailable( + "BUILD", + "runtime preflight skipped because `agents:` is not a list; " + "ventis build owns and reports this error.", + ) + return report + + entrypoints = [] + for entry in entries: + if not isinstance(entry, dict) or entry.get("type", "agent") == "workflow": + continue + name = entry.get("name") + entrypoint = entry.get("entrypoint") + if isinstance(entrypoint, str): + entrypoints.append(entrypoint) + if name in agents_by_name: + yaml_path, agent_block = agents_by_name[name] + check_adapter(report, yaml_path, agent_block, entry, project_dir) + entrypoint_path = os.path.join(project_dir, entrypoint or "") + if isinstance(entrypoint, str) and os.path.isfile(entrypoint_path): + check_requirements_coverage( + report, project_dir, entry, entrypoint_path, config_path + ) + + for entry in entries: + if not isinstance(entry, dict) or entry.get("type", "agent") != "workflow": + continue + workflow_file = entry.get("workflow_file") + if not isinstance(workflow_file, str): + continue + workflow_path = os.path.join(project_dir, workflow_file) + if os.path.isfile(workflow_path): + check_workflow(report, workflow_path, stub_classes) + + # These survive a green build and otherwise surface only in a container or + # on its first request. + check_flat_collisions(report, project_dir, yaml_paths, entrypoints) + check_env_file(report, config, config_path, project_dir) + + entrypoint_paths = [ + os.path.join(project_dir, e) + for e in entrypoints + if os.path.isfile(os.path.join(project_dir, e)) + ] + check_import_root(report, project_dir, entrypoint_paths) + + port_paths = list(entrypoint_paths) + for entry in entries: + if isinstance(entry, dict) and isinstance(entry.get("workflow_file"), str): + candidate = os.path.join(project_dir, entry["workflow_file"]) + if os.path.isfile(candidate): + port_paths.append(candidate) + + # Secret detection remains because a green image build would permanently + # bake the credential into every image. + check_secrets(report, port_paths) + return report + + + +# ------------------------------------------------------------------ # +# Output # +# ------------------------------------------------------------------ # + +LEVEL_ORDER = {ERROR: 0, WARN: 1, INFO: 2} + + +def _wrap(text, width, indent): + words = text.split() + lines = [] + current = "" + for word in words: + candidate = f"{current} {word}".strip() + if len(candidate) + len(indent) > width and current: + lines.append(indent + current) + current = word + else: + current = candidate + if current: + lines.append(indent + current) + return lines + + +def print_report(report, project_dir): + caps = report.capabilities + if not caps.get("ventis"): + print("ventis is not importable here -- capability-gated rules are") + print("reported UNAVAILABLE rather than checked.\n") + else: + print("CanyonOS Core capabilities detected:") + for key, source in CAPABILITY_SOURCE.items(): + mark = "yes" if caps.get(key) else "no " + print(f" {mark} {key:<22} {source}") + print() + + findings = sorted( + report.findings, + key=lambda f: (LEVEL_ORDER[f["level"]], f["check"], f["path"], f["line"]), + ) + for finding in findings: + where = finding["path"] + if where and finding["line"]: + where = f"{where}:{finding['line']}" + header = f"{finding['check']} {finding['level']:<5}" + print(f"{header} {where}" if where else header) + for line in _wrap(finding["summary"], 78, " "): + print(line) + if finding["mechanism"]: + for line in _wrap(finding["mechanism"], 78, " "): + print(line) + print() + + errors, warnings = report.counts() + if not findings: + print(f"{project_dir}: clean.") + return + print(f"{errors} error(s), {warnings} warning(s).") + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Check a CanyonOS Core port against the rules in SKILL.md." + ) + parser.add_argument( + "project_dir", nargs="?", default=".", help="the port's project root" + ) + parser.add_argument( + "-c", + "--config", + default=DEFAULT_CONFIG_PATH, + help=f"config path relative to project_dir (default: {DEFAULT_CONFIG_PATH})", + ) + parser.add_argument("--json", action="store_true", help="emit findings as JSON") + parser.add_argument( + "--strict", action="store_true", help="fail on warnings as well as errors" + ) + args = parser.parse_args(argv) + + project_dir = os.path.abspath(args.project_dir) + config_path = ( + args.config + if os.path.isabs(args.config) + else os.path.join(project_dir, args.config) + ) + + capabilities = probe_capabilities() + report = validate(project_dir, config_path, capabilities) + errors, warnings = report.counts() + + if args.json: + print( + json.dumps( + { + "project_dir": project_dir, + "capabilities": capabilities, + "errors": errors, + "warnings": warnings, + "findings": report.findings, + }, + indent=2, + ) + ) + else: + print_report(report, report.rel(project_dir) or project_dir) + + if errors or (args.strict and warnings): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.gitignore b/.gitignore index de8776e..085b4a3 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,7 @@ AWSCLIV2.pkg uv.lock Agent Artifacts -docs/ \ No newline at end of file +docs/ +# testing-porting-to-ventis working tree: clones, artifacts, results db +.ventis-tests/ +.harness/ diff --git a/README.md b/README.md index 5236328..81d944f 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,15 @@ cp -r ../examples/* ./ #### Step 1: Configure the Global Controller Edit `config/global_controller.yaml` in your project directory to list the agents you want to deploy, their `provider`, `replicas`, and resource limits. Add a per-agent `requirements: [pkg, ...]` list for any extra pip packages that agent's code imports — only a small base list (grpc, redis, pyyaml, psutil, etc.) is installed by default. +#### Step 1.1: Passing secrets to agents (optional) + +Agents that need API keys read them from environment variables. Point `env_file` at a `.env` file to have Ventis inject it into every agent container: + +```yaml +# config/global_controller.yaml +env_file: .env +``` + #### Step 2: Build the project ```bash ventis build diff --git a/examples/joke_writer/.env.example b/examples/joke_writer/.env.example new file mode 100644 index 0000000..b846149 --- /dev/null +++ b/examples/joke_writer/.env.example @@ -0,0 +1,20 @@ +# Copy this to `.env` and fill in the token. `config/global_controller.yaml` +# points `env_file:` at that copy, and it reaches every container as +# `docker run --env-file`. +# +# Keep the real token out of THIS file. `.env.example` is the one exception to +# the build context's exclusion of `.env*`, so whatever is written here is baked +# into the image; `.env` itself never enters the build and never leaves the host. + +# A Bedrock API key -- the long-term kind generated in the console, or a +# short-term one. botocore matches this exact name against bedrock-runtime's +# signingName (`bedrock`) and switches the client from SigV4 to bearer auth by +# itself, which is why neither joke_writer.py nor ventis/llm/bedrock.py mentions +# it. An IAM access key works too: drop AWS_ACCESS_KEY_ID and +# AWS_SECRET_ACCESS_KEY in instead and the same client signs with SigV4. +AWS_BEARER_TOKEN_BEDROCK= + +# Neither is a secret, and both have defaults in joke_writer.py -- they are here +# to name what the source reads. +BEDROCK_MODEL_ID=meta.llama3-8b-instruct-v1:0 +AWS_REGION=us-east-1 diff --git a/examples/joke_writer/LICENSE b/examples/joke_writer/LICENSE new file mode 100644 index 0000000..5600729 --- /dev/null +++ b/examples/joke_writer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 LangChain, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/joke_writer/README.md b/examples/joke_writer/README.md new file mode 100644 index 0000000..3ba7930 --- /dev/null +++ b/examples/joke_writer/README.md @@ -0,0 +1,183 @@ +# Joke Writer + +A LangGraph map-reduce, ported to Ventis. Derived from +[langchain-ai/langchain-academy](https://github.com/langchain-ai/langchain-academy) +at `fa15bec` (`module-4/studio/map_reduce.py`, MIT — see `LICENSE`). + +Unlike the other targets in `examples/`, **the source here is not unmodified**. +`map_reduce.py` built a `ChatOpenAI` at module scope, and at the time of the port +an agent container had no way to carry an `OPENAI_API_KEY` — the port was blocked +at the credential wall until the model call was rewritten onto Bedrock. That wall +is gone now: `env_file` puts any key in the container. The Bedrock rewrite stayed +anyway, and [What the port cost](#what-the-port-cost) is honest about what that +means. + +## Overview + +Given a topic, the graph splits it into sub-topics, writes one joke per +sub-topic in parallel, then picks the best of them. + +1. `generate_topics` — one LLM call, turns the topic into three sub-topics, + validated into `Subjects`. +2. `generate_joke` — one LLM call per sub-topic. `continue_to_jokes` emits a + `Send` per subject, so this node runs N times per request with no shared + state between the runs. `jokes` is an `Annotated[list, operator.add]`, which + is how the N results merge back into one state. +3. `best_joke` — one LLM call over every joke, returns the winner by index. + +``` + START + | + generate_topics 1 call + | + continue_to_jokes Send x N + / | \ + joke joke joke N calls, no shared state + \ | / + best_joke 1 call + | + END +``` + +### Why this one + +It is the smallest project in reach whose control flow does something a single +process cannot: `Send` fans out to N independent calls per request. Everything +else about it is deliberately boring — four packages, no tools, no external +service, one API key. + +## The port + +| File | What it holds | +| --- | --- | +| `joke_writer.py` | The source. Three prompts, two schemas, three nodes, and the graph — still compiled, never executed under Ventis. | +| `agents/joke_agent.py` | `JokeAgent`. Three methods, each calling the source's node with the node's own state dict. Imports `joke_writer`; restates nothing. | +| `agents/joke_agent.yaml` | The three nodes declared as three functions on one agent. | +| `workflow/joke_workflow.py` | Where the graph went — the edges, the `Send` fan-out and the `operator.add` reducer, re-expressed as ordinary Python. | +| `config/global_controller.yaml` | `JokeAgent` at `replicas: 3`, plus the workflow. | +| `config/policy.yaml` | Default-allow for the two services. Not optional — a missing file kills `ventis deploy`. | + +Two decisions worth naming: + +**One agent, not three.** `generate_topics` and `best_joke` run once per request +and have no resource profile of their own. Splitting them out would buy two more +images and two more Redis round trips. What is hoisted is the fan-out, and that +is a workflow concern. + +**The graph is not the port.** `StateGraph`, `Send` and the `Annotated[list, +operator.add]` reducer are control flow owned by the LangGraph runtime, and +Ventis has no runtime to execute them. The workflow dispatches N +`generate_joke` calls across the three replicas and concatenates the results +itself. Every call is dispatched before any is resolved — `.value()` blocks, so +fusing the two lines into one comprehension would silently serialize the fan-out +and remove the reason to be on Ventis at all. + +## What the port cost + +This is no longer upstream's model stack. `ChatOpenAI` and +`with_structured_output` are gone; `ventis.llm.bedrock.call_bedrock` is the raw +converse API, so each node asks for JSON in its prompt and validates the reply +through the same pydantic schema upstream used. `_extract_json` exists only +because `with_structured_output` used to do that work. + +That rewrite is not something the `porting-to-canyonos-core` skill should do on a +user's project — it is the credential wall, and the skill's instruction is to +report it. It was done here deliberately, so that this example is one that +actually deploys. + +**It would not be necessary today.** The rewrite bought one thing: boto3 builds +no client at import, so the agent could be *loaded* with no secret in the +container, back when `_launch_locally` passed five `-e` flags and all five were +`VENTIS_*`. `env_file` removes that constraint — an `OPENAI_API_KEY` now reaches +a container as readily as a Bedrock one, and upstream's `ChatOpenAI` at module +scope would import fine. What the rewrite still buys is narrower: a module-scope +client turns a missing key into `"No agent loaded"`, while a per-call one turns +it into a real error on `/status`. Worth knowing, not worth a rewrite. + +The example stays on Bedrock because it is the model call that has been end-to-end +verified here, and because `ventis/llm/bedrock.py` is where Ventis writes per-call +token telemetry onto the future. + +## Running it + +Copy `.env.example` to `.env` and put a Bedrock API key in it: + +```shell +cp .env.example .env +$EDITOR .env # AWS_BEARER_TOKEN_BEDROCK=bedrock-api-key-... +``` + +> **`env_file:` needs PR #53** (`jiajunh/can-232-...`), still open against main. +> Until it merges nothing in `ventis/` reads the key, so the steps below leave +> the container without a credential and every request answers a Bedrock +> credential error. `python ../../.claude/skills/porting-to-canyonos-core/validate.py .` +> reports this as V030 and stops reporting it the day the PR lands. + +`config/global_controller.yaml` points `env_file:` at that file, and every +container gets it as `docker run --env-file`. Nothing in this project reads the +variable: botocore matches the name against `bedrock-runtime`'s signingName and +switches the client from SigV4 to bearer auth on its own, so +`ventis/llm/bedrock.py` still builds a plain `boto3.client("bedrock-runtime")`. +An IAM access key instead of the bearer token works the same way. + +`.env` is gitignored and excluded from the build context — the key is in the +container's environment and not in the image. Deploy checks the path before it +launches anything, so a missing `.env` is one error line rather than three +replicas that come up and fail every request. + +```shell +ventis build +ventis deploy +``` + +```shell +curl -X POST http://localhost:8080/main \ + -H 'Content-Type: application/json' -d '{"query": "animals"}' +curl http://localhost:8080/status/ +``` + +```json +{"request_id": "cb6cb62d...", "status": "done", "result": { + "topic": "animals", + "subjects": ["Wildlife Conservation", "Animal Behavior", "Endangered Species"], + "jokes": ["...", "...", "..."], + "best_selected_joke": "Why did the chimpanzee go to the doctor? Because it was going bananas!" +}} +``` + +`BEDROCK_MODEL_ID` and `AWS_REGION` (see `.env.example`) have defaults in +`joke_writer.py`; neither is a secret. The region has to match the one the key +was issued for. + +### Running the source outside Ventis + +`joke_writer.py` imports `ventis.llm.bedrock` first and falls back to the flat +`bedrock` copy an agent image gets, so the compiled graph still runs on its own +from a checkout of this repo: + +```shell +pip install -e ../.. # the ventis package +pip install langgraph pydantic typing_extensions boto3 +``` + +```python +from joke_writer import graph + +graph.invoke({"topic": "animals"}) +``` + +## Provenance + +Taken from `module-4/studio/`, which holds four unrelated graphs sharing one +directory. Only `map_reduce.py` and its license are here. + +| Left behind | Why | +| --- | --- | +| `parallelization.py`, `research_assistant.py`, `sub_graphs.py` | Other graphs in the same studio directory. The first two also need a Tavily key and Wikipedia. | +| `langgraph.json` | Registers all four graphs and points at `./.env`; a trimmed copy would only be useful for LangGraph Studio. | +| The module-4 notebooks | Teaching material for the same code. | +| `OPENAI_API_KEY`, `TAVILY_API_KEY` in `.env.example` | The first belongs to a model call that is no longer here; the second to the two graphs that are not here. | + +Nothing was added at the project root: there is no `pyproject.toml`, `setup.py` +or `requirements.txt`, exactly as upstream has none for module-4. That is why +`config/global_controller.yaml` has to declare `requirements:` by hand. diff --git a/examples/joke_writer/agents/joke_agent.py b/examples/joke_writer/agents/joke_agent.py new file mode 100644 index 0000000..8fa3b93 --- /dev/null +++ b/examples/joke_writer/agents/joke_agent.py @@ -0,0 +1,63 @@ +"""Ventis entrypoint for the map-reduce joke writer. + +Nothing here restates the project. The three prompts, the two schemas and the +Bedrock binding all live in `joke_writer.py` and are reached with an import -- +the whole project tree is in the image. + +What could not be reused is the graph itself. `StateGraph`, the `Send` in +`continue_to_jokes` and the `Annotated[list, operator.add]` reducer are control +flow owned by the LangGraph runtime, and Ventis has no runtime to execute them. +That wiring is re-expressed as ordinary Python in workflow/joke_workflow.py, +where the fan-out becomes N dispatched calls across this agent's replicas. The +nodes those edges connected are imported, unchanged. + +The module is imported whole rather than by name so that +`joke_writer.generate_joke` inside a method named `generate_joke` reads as what +it is: the source's node. +""" + +# The source tree. Importing it reads BEDROCK_MODEL_ID and AWS_REGION, imports +# bedrock.py (which builds a RedisClient at module scope) and compiles the graph +# -- but it constructs no API client, so the import needs no credential. +# +# The credential arrives by a different road: `env_file` in +# config/global_controller.yaml hands the container a .env holding +# AWS_BEARER_TOKEN_BEDROCK, and botocore picks that name up by itself. Nothing +# here or in joke_writer.py names it. +# +# Constructing no client at import is no longer what makes this agent loadable -- +# env_file would carry a key to a module-scope client too. It only changes the +# failure: a missing key is an error on /status rather than "No agent loaded". +import joke_writer + + +class JokeAgent(object): + """The graph's nodes, exposed under the class name `agent.name` declares.""" + + # No constructor arguments -- LocalController does `JokeAgent()`. The model + # id and region are the source's own module-level constants, read from the + # environment there; there is nothing to configure here. + + def generate_topics(self, topic: str) -> dict: + """Split a topic into sub-topics. Returns {"subjects": [...]}. + + Synchronous by signature -- the executor calls this with no `await`, and + returning a coroutine would put `` into Redis. + """ + # The node's own state dict goes in, the node's own return comes out. + # Both hold nothing but str and list, so the executor's json.dumps is + # happy without a serializer -- unlike a graph that hands back messages. + return joke_writer.generate_topics({"topic": topic}) + + def generate_joke(self, subject: str) -> dict: + """Write one joke about one subject. Returns {"jokes": ["..."]}. + + The single-element list is the node's own shape: it is what + `Annotated[list, operator.add]` merged N of. The workflow does that + concatenation now. + """ + return joke_writer.generate_joke({"subject": subject}) + + def best_joke(self, topic: str, jokes: list) -> dict: + """Pick the winner. Returns {"best_selected_joke": "..."}.""" + return joke_writer.best_joke({"topic": topic, "jokes": jokes}) diff --git a/examples/joke_writer/agents/joke_agent.yaml b/examples/joke_writer/agents/joke_agent.yaml new file mode 100644 index 0000000..fee8607 --- /dev/null +++ b/examples/joke_writer/agents/joke_agent.yaml @@ -0,0 +1,53 @@ +# The graph's three nodes, exposed as three methods on one agent. +# +# One agent, not three. `generate_topics` and `best_joke` run once per request +# and have no resource profile of their own, so splitting them out would add +# two images, two dependency trees and a Redis round trip to buy nothing. What +# is hoisted is the `Send` fan-out, and that is a workflow concern, not a +# second agent: the workflow dispatches N `generate_joke` calls and the +# routing table spreads them across this agent's replicas. +# +# This file's basename names the generated stub, not the agent. Sharing it with +# joke_agent.py is why both land at /app/joke_agent.py -- the entrypoint is +# copied last and wins it, so the agent container loads the real class while the +# stub keeps /app/agents/joke_agent.py for callers. What the basename must not +# match is a source module: a `joke_writer.yaml` would put a stub at +# /app/joke_writer.py, on top of the module the adapter imports. + +agent: + name: JokeAgent + functions: + # Node 1 of the graph. One LLM call, structured output into `Subjects`. + - name: generate_topics + description: Split a topic into three related sub-topics. + arguments: + # Must equal the Python parameter name character for character -- + # LocalController calls method(**args). + - name: topic + type: str + # dict -> the workflow must json.loads what .value() hands back + returns: + type: dict + + # Node 2. The fan-out: one call per sub-topic, no shared state between + # them. This is the only reason this project is on Ventis. + - name: generate_joke + description: Write one joke about one subject. + arguments: + - name: subject + type: str + returns: + type: dict + + # Node 3. The reduce: one call over every joke the fan-out produced. + - name: best_joke + description: Pick the best joke out of the ones written for a topic. + arguments: + - name: topic + type: str + # `list` is a builtin, so the stub's annotation resolves. `list[str]` + # would be pasted into the AST verbatim and NameError on import. + - name: jokes + type: list + returns: + type: dict diff --git a/examples/joke_writer/config/global_controller.yaml b/examples/joke_writer/config/global_controller.yaml new file mode 100644 index 0000000..eb26090 --- /dev/null +++ b/examples/joke_writer/config/global_controller.yaml @@ -0,0 +1,82 @@ +# Deployment manifest for the map-reduce joke writer. +# +# `entrypoint` is the adapter, which imports the untouched-in-shape source tree. +# +# The source has no pyproject.toml, setup.py or setup.cfg, so the Dockerfile's +# `-e .` is skipped -- silently. It does not matter here: `joke_writer.py` sits +# at the project root, so it lands flat at /app, which is sys.path[0]. A source +# laid out under src/ would need its own packaging metadata to import at all. + +agents: + - name: JokeAgent + # The fan-out. `generate_joke` is stateless, so LocalController picks a + # replica at random per call and the workflow's N dispatched calls spread + # across these three. N is whatever the model returns (the prompt asks for + # three sub-topics); replicas bound how many run at once, not how many run. + replicas: 3 + redis_port: 6379 + resources: + cpu: 1 + memory: 1024 + entrypoint: agents/joke_agent.py + provider: local + # What the source imports beyond the runtime's own list, which the generator + # prepends. boto3 is already in it, which is the whole reason the Bedrock + # call needs nothing declared here. The graph is never executed in this + # container, but `joke_writer.py` imports langgraph at module scope, so it + # still has to be installed. + requirements: + - langgraph + - pydantic + - typing_extensions + + - name: Workflow + type: workflow + replicas: 1 + redis_port: 6379 + api_port: 8080 + workflow_file: workflow/joke_workflow.py + provider: local + +poll_interval: 5 + +redis: + host: localhost + port: 6379 + db: 0 + +# `provider` must be lowercase. InstanceManager.launch_all tests +# `provider == "local"` to decide whether to reserve a host port; `Local` fails +# that test, reserved_port stays None, and Local/_runtime.py raises +# `int() argument must be ... not 'NoneType'` before any container starts. +# +# The credential. `_launch_locally` passes exactly five `-e` flags, all VENTIS_*, +# and .env is excluded from the build context, so for a while the only model call +# that could work here was one that needed no secret in the container: boto3 +# resolving an instance role per call. `env_file` is what changed. It points at a +# local .env, unresolved paths relative to this project root, and every container +# gets it as `docker run --env-file` -- so the key is in the environment without +# ever entering the image. +# +# What lands there is AWS_BEARER_TOKEN_BEDROCK. Nothing in this project reads it: +# botocore matches the name against bedrock-runtime's signingName and switches +# the client to bearer auth on its own, so `ventis/llm/bedrock.py` still builds a +# plain `boto3.client("bedrock-runtime")`. +# +# Deploy fails here rather than in a container: resolve_env_file checks the path +# before InstanceManager launches anything, so a missing .env is one error line +# instead of three replicas that come up and then answer +# {"status": "error", "error": "Unable to locate credentials"} on every request. +# +# What it costs: this is no longer upstream's model stack. See README.md. + +# Relative to this project root, same as `entrypoint` and `workflow_file`. +# .env is gitignored and excluded from the build context; .env.example names +# what belongs in it. +# +# NOTE: this key needs PR #53 (jiajunh/can-232-...), which is still open against +# main. On main nothing reads it -- `grep -rn env_file ventis/` finds no hits -- +# so the key is inert, no credential reaches the container, and every request +# answers a Bedrock credential error. `validate.py` reports that as V030 until +# the PR lands. +env_file: .env diff --git a/examples/joke_writer/config/policy.yaml b/examples/joke_writer/config/policy.yaml new file mode 100644 index 0000000..2cb9cb3 --- /dev/null +++ b/examples/joke_writer/config/policy.yaml @@ -0,0 +1,20 @@ +# Policy-Based Routing Rules — map-reduce joke writer +# Each rule defines a match condition (key-value pairs checked against the +# request context) and an access list of allowed services. +# Rules are evaluated most-specific-first (most matching keys wins). +# An empty match ({}) acts as a default fallback. +# +# This file IS optional -- `_load_policy_rules` logs "No policy file found" and +# returns [], and `_check_policy` allows everything when the rule list is empty. +# What is not safe is a half-written one: past the isfile() guard the read is +# unguarded, so an empty file (`.get("rules")` on None) or a null `rules:` +# (`None.sort()`) raises inside GlobalController.__init__ and `ventis deploy` +# dies before any container starts. Delete it or fill it; do not leave it empty. + +rules: + # Default fallback: the workflow and the one agent behind it. A service left + # out of this list answers "Unauthorized: Policy denied access to service". + - match: {} + access: + - Workflow + - JokeAgent diff --git a/examples/joke_writer/joke_writer.py b/examples/joke_writer/joke_writer.py new file mode 100644 index 0000000..3ad49d5 --- /dev/null +++ b/examples/joke_writer/joke_writer.py @@ -0,0 +1,151 @@ +"""Map-reduce joke writer. + +Derived from langchain-ai/langchain-academy `module-4/studio/map_reduce.py` +(MIT, see LICENSE). The graph shape, the three prompts and the two schemas are +upstream's. The model call is not: upstream builds a `ChatOpenAI` at module +scope, and when this was ported nothing could carry an OPENAI_API_KEY into an +agent container. Bedrock reaches the model through boto3, which builds no client +at import, so the same code loaded with no secret injected. + +`env_file` has since removed that constraint -- the key now travels to the +container in a .env and botocore reads AWS_BEARER_TOKEN_BEDROCK on its own. The +rewrite stayed regardless; README.md says what that costs. + +`with_structured_output` went with it. `call_bedrock` is the raw converse API, so +each node asks for JSON in the prompt and validates the reply through the same +pydantic schema upstream used. +""" + +import json +import operator +import os +import re +from typing import Annotated + +from typing_extensions import TypedDict + +from pydantic import BaseModel, ValidationError + +from langgraph.constants import Send +from langgraph.graph import END, StateGraph, START + +# Ventis copies bedrock.py flat into every agent image; the package path is for +# running this module outside a container. +try: + from ventis.llm.bedrock import call_bedrock +except ImportError: + from bedrock import call_bedrock + +# Prompts we will use. Upstream's, plus the JSON instruction that +# `with_structured_output` used to add on our behalf. +subjects_prompt = """Generate a list of 3 sub-topics that are all related to this overall topic: {topic}. +Respond with JSON only, no prose: {{"subjects": ["...", "...", "..."]}}""" +joke_prompt = """Generate a joke about {subject}. +Respond with JSON only, no prose: {{"joke": "..."}}""" +best_joke_prompt = """Below are a bunch of jokes about {topic}. Select the best one! Return the ID of the best one, starting 0 as the ID for the first joke. Jokes: \n\n {jokes} +Respond with JSON only, no prose: {{"id": 0}}""" + +# LLM. Both are read once at import; the container gets them from its +# environment, and neither is a secret. +MODEL_ID = os.environ.get("BEDROCK_MODEL_ID", "meta.llama3-8b-instruct-v1:0") +REGION = os.environ.get("AWS_REGION", "us-east-1") + + +def _extract_json(text): + """Pull the first JSON object out of a model reply. + + Even told to answer with JSON only, a model wraps it in a ```json fence or + prefaces it with a sentence. Upstream never needed this because + `with_structured_output` handled it; the converse API does not. + """ + text = re.sub(r"^\s*```(?:json)?|```\s*$", "", text.strip(), flags=re.MULTILINE) + try: + return json.loads(text) + except json.JSONDecodeError: + pass + # Fall back to the outermost braced span. + match = re.search(r"\{.*\}", text, flags=re.DOTALL) + if not match: + raise ValueError(f"joke_writer: no JSON in model output: {text!r}") + return json.loads(match.group(0)) + + +def _ask(prompt, schema, max_tokens): + """One converse() call, validated into `schema`. + + Raising on a bad reply is deliberate. A node that returned a default would + put a plausible-looking wrong answer into the state, and the reduce step + downstream indexes into the jokes list by an id the model chose -- a silent + default there picks the wrong joke instead of failing. + """ + response = call_bedrock( + model_id=MODEL_ID, + messages=[{"role": "user", "content": [{"text": prompt}]}], + inference_config={"maxTokens": max_tokens, "temperature": 0.0}, + region=REGION, + ) + text = response["output"]["message"]["content"][0]["text"] + if not text: + raise ValueError("joke_writer: LLM returned no output.") + try: + return schema(**_extract_json(text)) + except (ValidationError, TypeError) as exc: + raise ValueError( + f"joke_writer: {schema.__name__} not satisfied by model output: {text!r}" + ) from exc + + +# Define the state +class Subjects(BaseModel): + subjects: list[str] + +class BestJoke(BaseModel): + id: int + +class OverallState(TypedDict): + topic: str + subjects: list + jokes: Annotated[list, operator.add] + best_selected_joke: str + +def generate_topics(state: OverallState): + prompt = subjects_prompt.format(topic=state["topic"]) + response = _ask(prompt, Subjects, max_tokens=300) + return {"subjects": response.subjects} + +class JokeState(TypedDict): + subject: str + +class Joke(BaseModel): + joke: str + +def generate_joke(state: JokeState): + prompt = joke_prompt.format(subject=state["subject"]) + response = _ask(prompt, Joke, max_tokens=300) + return {"jokes": [response.joke]} + +def best_joke(state: OverallState): + jokes = "\n\n".join(state["jokes"]) + prompt = best_joke_prompt.format(topic=state["topic"], jokes=jokes) + response = _ask(prompt, BestJoke, max_tokens=100) + if not 0 <= response.id < len(state["jokes"]): + raise ValueError( + f"joke_writer: model chose joke {response.id} of {len(state['jokes'])}." + ) + return {"best_selected_joke": state["jokes"][response.id]} + +def continue_to_jokes(state: OverallState): + return [Send("generate_joke", {"subject": s}) for s in state["subjects"]] + +# Construct the graph: here we put everything together to construct our graph +graph_builder = StateGraph(OverallState) +graph_builder.add_node("generate_topics", generate_topics) +graph_builder.add_node("generate_joke", generate_joke) +graph_builder.add_node("best_joke", best_joke) +graph_builder.add_edge(START, "generate_topics") +graph_builder.add_conditional_edges("generate_topics", continue_to_jokes, ["generate_joke"]) +graph_builder.add_edge("generate_joke", "best_joke") +graph_builder.add_edge("best_joke", END) + +# Compile the graph +graph = graph_builder.compile() diff --git a/examples/joke_writer/workflow/joke_workflow.py b/examples/joke_writer/workflow/joke_workflow.py new file mode 100644 index 0000000..9fdf3cc --- /dev/null +++ b/examples/joke_writer/workflow/joke_workflow.py @@ -0,0 +1,59 @@ +r"""Ventis workflow for the map-reduce joke writer. + +This file is where the graph went. `generate_topics -> continue_to_jokes -> +generate_joke x N -> best_joke` is not a compiled StateGraph any more; it is the +three statements below, and the `Send` fan-out is N calls dispatched across +JokeAgent's replicas. + +The function is `main` and its one argument is `query` because the deployment +platform's test endpoint posts to a hardcoded /main with a strictly validated +{query: string} body. Ventis would serve any name and any kwargs -- the route is +the function's __name__ and the body is splatted in -- so nothing here fails if +you rename it; it just stops being reachable through the platform. + + curl -X POST http://localhost:8080/main \ + -H 'Content-Type: application/json' -d '{"query": "animals"}' + curl http://localhost:8080/status/ +""" + +import json + +from deploy import deploy +from agents.joke_agent import JokeAgent + + +def main(query): + """Route: POST /main {"query": ""}""" + agent = JokeAgent() + + # Node 1: one call, and the fan-out width comes out of it. The agent's own + # parameter is still `topic` -- that name is bound by joke_agent.yaml and the + # source's node, and only the workflow's entry point is pinned to `query`. + subjects = json.loads(agent.generate_topics(topic=query).value())["subjects"] + + # `continue_to_jokes`, re-expressed. Every call is dispatched before any + # of them is resolved -- .value() blocks, so fusing these two lines into one + # comprehension would run the jokes one after another. It would not error; + # the fan-out would just be gone, and with it the reason to be on Ventis. + futures = [agent.generate_joke(subject=s) for s in subjects] + written = [json.loads(f.value()) for f in futures] + + # `Annotated[list, operator.add]`, re-expressed: the reducer that merged N + # single-joke lists back into one list was part of the graph, not of a node. + written_jokes = [joke for result in written for joke in result["jokes"]] + + # Node 3: the reduce. `list` in the yaml is what lets this argument through. + best = json.loads(agent.best_joke(topic=query, jokes=written_jokes).value()) + + return { + "topic": query, + "subjects": subjects, + "jokes": written_jokes, + "best_selected_joke": best["best_selected_joke"], + } + + +# This file is exec'd, not imported, so __name__ == "__main__" here and any +# `if __name__ == "__main__":` block would run in production. deploy() blocks +# on app.run(); nothing after it executes. +deploy(main, port=8080) diff --git a/examples/portfolio/agents/metrics_agent.py b/examples/portfolio/agents/metrics_agent.py index 6fb375f..d5f722e 100644 --- a/examples/portfolio/agents/metrics_agent.py +++ b/examples/portfolio/agents/metrics_agent.py @@ -8,6 +8,7 @@ # downstream RiskAgent can build the portfolio covariance. # # Resource profile: cheap CPU, high fan-out — one compute() call per holding. +import os import sys import os