Metadata-Version: 2.4
Name: boilerplate-devops-investigation-agent
Version: 0.2.1
Summary: Generic base for DevOps investigation agents: investigation-phase/1.0.0 protocol, async HTTP shell and Redis result writer
License-Expression: MIT
Requires-Python: <4,>=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastapi<1,>=0.115
Requires-Dist: langchain-core<2,>=1.0
Requires-Dist: pydantic<3,>=2.10
Requires-Dist: redis<9,>=5
Requires-Dist: uvicorn<1,>=0.30
Provides-Extra: aws
Requires-Dist: boto3<2,>=1.35; extra == "aws"
Requires-Dist: langchain-aws<2,>=1.8; extra == "aws"
Provides-Extra: deep-agent
Requires-Dist: deepagents==0.7.22; extra == "deep-agent"
Dynamic: license-file

# boilerplate-devops-investigation-agent

The generic base for a DevOps investigation agent. Import it as `devops_agent`.
A concrete agent installs this package and adds its own phase handlers, prompts and
models. This package holds no prompts, models, accounts or log sources.

It provides:

- the `investigation-phase/1.0.0` protocol: JSON schemas (draft 2020-12) and pydantic models;
- an async HTTP shell with the AgentCore contract: `GET /ping` and `POST /invocations`;
- a result writer that appends one Run event to a Redis stream with `XADD`;
- an invocation store, so a repeated request starts no new work;
- a per-job limit and deadline guard that counts every model call and query;
- a bounded CloudWatch Logs read tool;
- optional parts: a Deep Agent factory (`devops_agent.agent`, extra `deep-agent`) and a
  Podman code worker (`devops_agent.sandbox`).

## Install

```sh
pip install boilerplate-devops-investigation-agent
pip install "boilerplate-devops-investigation-agent[deep-agent]"  # optional Deep Agent factory
```

## How a phase job runs

1. The harness sends a request to `POST /invocations`.
2. The shell checks it. Unknown fields, `run_settings` and a wrong phase and operation pair
   are refused with 422. A repeated `invocation_id` with a different body gets 409.
3. The shell replies at once with the acknowledgement:
   `{"protocol_version": "investigation-phase/1.0.0", "invocation_id": "...", "request_sha256": "...", "accepted": true}`.
4. The phase handler runs in a worker thread. `/ping` reports `HealthyBusy` until it ends.
5. The writer appends exactly one entry to the request's `result_stream`:
   `XADD <result_stream> * json <document>`. The document is a canonical Run event of kind
   `phase_result` (format version 2). Its `payload` is the protocol result.

The `invocation_id` is opaque text of 1 to 256 characters, for example `run-1:0:frame`.
The base never parses it. The acknowledgement and the result carry it back unchanged.

A handler failure, a limit or a passed deadline still writes one result, with
`status: "error"` and `error.kind` set to `handler`, `limit`, `deadline` or `invalid_output`.

A failed Redis write is logged and not retried, because a retry could write a second result.
A repeated request with the same body then gets the acknowledgement again but starts no work.
Only the harness phase deadline ends that job.

The invocation store is in memory by default. After an agent restart, a repeated request runs
again and may write a second result. The harness ignores a second result for the same
`invocation_id`, so this is safe.

## Wire a concrete agent

```python
import redis
from devops_agent.guard import PhaseGuard
from devops_agent.protocol import AgentInfo, PhaseRequest
from devops_agent.results import RedisResultWriter
from devops_agent.runtime import create_app


def frame(request: PhaseRequest, guard: PhaseGuard) -> dict:
    with guard.model_call(role="frame", model_id=MODEL_ID, max_microdollars=40_000) as call:
        reply = my_model.invoke(...)
        call.settle(input_tokens=..., output_tokens=..., microdollars=...)
    return {"directions": [...]}


app = create_app(
    {"FRAME": frame},  # one handler for each phase you serve
    writer=RedisResultWriter(redis.Redis.from_url(REDIS_URL, decode_responses=True)),
    agent=AgentInfo(version="1.0.0", commit=GIT_COMMIT, prompt_set_sha256=PROMPT_HASH),
)
```

Without a writer and an agent block, the app refuses every request with 503. A phase with no
handler is refused with 503 too.

The guard is made from the request's `limits` and `deadline`. Before each call, it reserves
the worst-case price, so spend never passes `money_microdollars`. A call that is not settled
keeps its whole reservation as spent. The first refusal stops the guard, so no model call
follows a limit. The guard's totals become the result's `cost`.

Give the CloudWatch tool the same guard, so each read counts as one query:

```python
from devops_agent.tools import make_read_logs_tool

read_logs = make_read_logs_tool(
    logs_client, allowed_groups=set(request.source.log_groups),
    start_ms=start_ms, end_ms=end_ms, guard=guard,
)
```

## Protocol files

The schemas ship inside the package:

```python
from devops_agent.protocol import load_schema

load_schema("request")  # also "acknowledgement" and "result"
```

They live at `devops_agent/protocol/investigation-phase/1.0.0/*.schema.json`.

| Phase | Operation | `input` | `output` |
| --- | --- | --- | --- |
| FRAME | `frame` | `leads` | `directions`: 1 to 4 objects with `window` and `hint` |
| QUALIFY | `qualify` | `directions` | `passed`, `reason` |
| GENERATE | `action` | `direction`, `diver_id` | `result`: `status`, `content`, `hypotheses`, `evidence`, `handoff` |
| RANK | `rank` | `candidates`: `id`, `content`, `listing` | `scores`: `id`, `score`, `reason` for each candidate |
| EXECUTE | `execute` | `selected`: `id`, `content`, `score`; `mode` | `plan` for `plan_log`, or `proposal` for `medic_proposal` |

Directions, diver results, plans and proposals are open objects. Only their required
top-level keys are checked.

## Local development

Install [uv](https://docs.astral.sh/uv/getting-started/installation/), Make and Podman.

```sh
make setup
make test
make start   # an unwired shell at http://127.0.0.1:8080/ping
```

The Redis tests start a disposable Podman Redis on `127.0.0.1` from
`docker.io/library/redis:7-alpine` and stop it afterwards. To use your own Podman Redis,
set `REDIS_URL=redis://127.0.0.1:<port>`. Never use a host Redis.

## Release

`make publish` checks that `PYPI_APITOKEN` starts with `pypi-`, builds the wheel and sdist,
runs `scripts/check_release_content.py` on them, uploads with uv and tags `v<version>`.
The content check refuses account ids, ARNs, AWS log group names, URLs that are not
allowed, access keys, private keys and tokens.

## License

MIT. See `LICENSE`.
