diff --git a/Makefile b/Makefile index 58a905e..fc3f7c3 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,9 @@ WINDOWS_ARCH_LIST = \ all: linux-amd64 darwin-amd64 darwin-arm64 windows-amd64 # Most used +local: ## Build for the current machine, output to ./greyproxy (used by scripts/test-matrix/run.sh) + CGO_ENABLED=0 go build --ldflags="$(LDFLAGS)" -o $(NAME) $(GOFILES) + darwin-amd64: GOARCH=amd64 GOOS=darwin $(GOBUILD) -o $(BINDIR)/$(NAME)-$@ $(GOFILES) diff --git a/cmd/greyproxy/main.go b/cmd/greyproxy/main.go index 6032630..7391927 100644 --- a/cmd/greyproxy/main.go +++ b/cmd/greyproxy/main.go @@ -34,7 +34,8 @@ var ( nodes stringList debug bool trace bool - metricsAddr string + metricsAddr string + silentAllow bool ) func init() { @@ -92,6 +93,7 @@ func parseFlags() { flag.BoolVar(&debug, "D", false, "debug mode") flag.BoolVar(&trace, "DD", false, "trace mode") flag.StringVar(&metricsAddr, "metrics", "", "metrics service address") + flag.BoolVar(&silentAllow, "silent-allow", false, "activate silent allow-all mode until restart") flag.Parse() if printVersion { diff --git a/cmd/greyproxy/program.go b/cmd/greyproxy/program.go index 5f92ff7..9af34b5 100644 --- a/cmd/greyproxy/program.go +++ b/cmd/greyproxy/program.go @@ -564,6 +564,57 @@ func (p *program) buildGreyproxyService() error { }() }) + // Wire WebSocket frame hook to store frames as transactions in the database + gostx.SetGlobalMitmWebSocketFrameHook(func(info gostx.MitmWebSocketFrameInfo) { + host, portStr, _ := net.SplitHostPort(info.Host) + if host == "" { + host = info.Host + } + port, _ := strconv.Atoi(portStr) + if port == 0 { + port = 443 + } + containerName, _ := greyproxy_plugins.ResolveIdentity(info.ContainerName, "") + go func() { + if len(info.Payload) == 0 { + return + } + payload := info.Payload + // If RSV1 is set, the frame uses permessage-deflate compression. + // Decompress without context takeover (append sync tail first). + if info.Rsv1 { + decompressed, err := decompressWebSocketFrame(payload) + if err != nil { + log.Debugf("ws frame decompress failed (rsv1=%v from=%s): %v", info.Rsv1, info.From, err) + } else { + payload = decompressed + } + } + method := "WS_REQ" + if info.From == "server" { + method = "WS_RESP" + } + txn, err := greyproxy.CreateHttpTransaction(shared.DB, greyproxy.HttpTransactionCreateInput{ + ContainerName: containerName, + DestinationHost: host, + DestinationPort: port, + Method: method, + URL: "wss://" + info.Host + info.URI, + RequestBody: payload, + StatusCode: 101, + Result: "auto", + }) + if err != nil { + log.Warnf("failed to store WebSocket frame: %v", err) + return + } + shared.Bus.Publish(greyproxy.Event{ + Type: greyproxy.EventTransactionNew, + Data: txn.ToJSON(false), + }) + }() + }) + // Wire MITM request-level hold hook: evaluate destination-level rules gostx.SetGlobalMitmHoldHook(func(ctx context.Context, info gostx.MitmRequestHoldInfo) error { host, portStr, _ := net.SplitHostPort(info.Host) @@ -592,6 +643,9 @@ func (p *program) buildGreyproxyService() error { // Create the allow-all manager (in-memory, resets on restart). allowAllManager := greyproxy.NewAllowAllManager(shared.Bus) shared.AllowAll = allowAllManager + if silentAllow { + allowAllManager.Enable(0, greyproxy.SilentModeAllow) // duration=0 means until restart + } // Initialize Docker resolver if configured. var dockerResolver greyproxy_plugins.ContainerResolver @@ -764,6 +818,21 @@ func decompressBody(body []byte, encoding string) []byte { return decoded } +// decompressWebSocketFrame decompresses a permessage-deflate WebSocket frame payload. +// The RSV1 bit signals per-frame deflate compression per RFC 7692. +// +// Go's compress/flate requires a BFINAL=1 block to terminate cleanly, unlike libz which +// handles SYNC_FLUSH (BFINAL=0) implicitly. The gorilla/websocket trick is to append both: +// - 0x00 0x00 0xff 0xff — the stripped SYNC_FLUSH terminator +// - 0x01 0x00 0x00 0xff 0xff — a BFINAL=1 empty stored block to signal end-of-stream +func decompressWebSocketFrame(payload []byte) ([]byte, error) { + const tail = "\x00\x00\xff\xff\x01\x00\x00\xff\xff" + mr := io.MultiReader(bytes.NewReader(payload), strings.NewReader(tail)) + r := flate.NewReader(mr) + defer r.Close() + return io.ReadAll(r) +} + // applyDockerEnvOverrides configures Docker resolution from environment variables. // Docker is disabled by default; use these env vars to opt in: // diff --git a/docs/llm-api-comparison.md b/docs/llm-api-comparison.md new file mode 100644 index 0000000..e10e01e --- /dev/null +++ b/docs/llm-api-comparison.md @@ -0,0 +1,202 @@ +# LLM API Comparison: Anthropic vs OpenAI + +Observed through greyproxy MITM traffic from Claude Code and OpenCode (March 2026). +This documents the wire format as seen by the proxy, not the full API specification. + +> **Scope**: Anthropic Messages API (`/v1/messages`) and OpenAI Responses API (`/v1/responses`). +> OpenAI Chat Completions (`/v1/chat/completions`) is not covered yet. + +## Endpoints + +| | Anthropic | OpenAI | +|---|---|---| +| **URL** | `POST https://api.anthropic.com/v1/messages` | `POST https://api.openai.com/v1/responses` | +| **Query params** | `?beta=true` (optional) | None observed | +| **Auth header** | `x-api-key: sk-ant-...` | `Authorization: Bearer sk-...` | +| **Streaming** | `stream: true` in body | `stream: true` in body | +| **Response type** | `text/event-stream` (SSE) | `text/event-stream` (SSE) | + +## Request Body Structure + +| Field | Anthropic | OpenAI | +|---|---|---| +| **Model** | `model: "claude-opus-4-6"` | `model: "gpt-5.1"` | +| **System prompt** | Separate `system` array of `{type, text}` blocks | `{role: "developer", content: "..."}` item inside `input[]` | +| **Messages** | `messages[]` with uniform `{role, content}` | `input[]` with heterogeneous items (see below) | +| **Tools** | `tools[]` with `{name, description, input_schema}` | `tools[]` with `{type: "function", name, description, parameters, strict}` | +| **Max tokens** | `max_tokens: 16384` | `max_output_tokens: 32000` | +| **Thinking/reasoning** | `thinking: {type: "enabled", budget_tokens: N}` | `reasoning: {effort: "medium", summary: "auto"}` | +| **Streaming config** | `stream: true` | `stream: true` | +| **Caching** | Implicit via `cache_control` on content blocks | `prompt_cache_key: "ses_XXX"` | +| **Tool choice** | `tool_choice: {type: "auto"}` | `tool_choice: "auto"` | + +## Message/Input Format + +This is the biggest structural difference between the two APIs. + +### Anthropic: `messages[]` + +All items have `{role, content}`. Content is either a string or array of typed blocks. + +```json +{ + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "..."}, + {"type": "text", "text": "Hi there!"}, + {"type": "tool_use", "id": "toolu_XXX", "name": "Bash", "input": {"command": "ls"}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_XXX", "content": "file1.txt\nfile2.txt"} + ]} + ] +} +``` + +### OpenAI: `input[]` + +Items are heterogeneous. Some have `role`, some have `type`, some have both. + +```json +{ + "input": [ + {"role": "developer", "content": "You are a coding agent..."}, + {"role": "user", "content": [{"type": "input_text", "text": "Hello"}]}, + {"type": "reasoning", "encrypted_content": "..."}, + {"type": "function_call", "call_id": "call_XXX", "name": "bash", "arguments": "{\"command\":\"ls\"}"}, + {"type": "function_call_output", "call_id": "call_XXX", "output": "file1.txt\nfile2.txt"}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Here are the files."}]} + ] +} +``` + +### Message Type Mapping + +| Concept | Anthropic | OpenAI | +|---|---|---| +| **System prompt** | `system: [{type: "text", text: "..."}]` (top-level) | `{role: "developer", content: "..."}` (in `input[]`) | +| **User message** | `{role: "user", content: "text"}` or `{role: "user", content: [{type: "text", text: "..."}]}` | `{role: "user", content: [{type: "input_text", text: "..."}]}` | +| **Assistant text** | `{role: "assistant", content: [{type: "text", text: "..."}]}` | `{type: "message", role: "assistant", content: [{type: "output_text", text: "..."}]}` | +| **Thinking** | `{type: "thinking", thinking: "..."}` content block | `{type: "reasoning", encrypted_content: "..."}` top-level item | +| **Tool call** | `{type: "tool_use", id: "toolu_XXX", name: "Read", input: {...}}` content block inside assistant message | `{type: "function_call", call_id: "call_XXX", name: "read", arguments: "{...}"}` top-level item | +| **Tool result** | `{type: "tool_result", tool_use_id: "toolu_XXX", content: "..."}` content block inside user message | `{type: "function_call_output", call_id: "call_XXX", output: "..."}` top-level item | + +Key differences: +- Anthropic nests tool calls inside assistant messages and tool results inside user messages +- OpenAI places them as top-level items in the `input[]` array +- Anthropic tool arguments are a JSON object; OpenAI stringifies them +- OpenAI reasoning is opaque (encrypted); Anthropic thinking is plaintext (when enabled) + +## SSE Response Events + +### Anthropic + +| Event | Description | +|---|---| +| `message_start` | Response metadata (model, usage) | +| `content_block_start` | New block: `{type: "text"}`, `{type: "tool_use", name: "..."}`, `{type: "thinking"}` | +| `content_block_delta` | Incremental content: `text_delta`, `input_json_delta`, `thinking_delta` | +| `content_block_stop` | Block finished | +| `message_delta` | Final usage stats, stop reason | +| `message_stop` | End of response | + +### OpenAI + +| Event | Description | +|---|---| +| `response.created` | Response metadata (id, model) | +| `response.in_progress` | Processing started | +| `response.output_item.added` | New output item: `{type: "reasoning"}`, `{type: "function_call", name: "..."}`, `{type: "message"}` | +| `response.output_text.delta` | Streamed text content | +| `response.function_call_arguments.delta` | Streamed tool call arguments | +| `response.function_call_arguments.done` | Complete tool call arguments | +| `response.reasoning_summary_text.delta` | Streamed reasoning summary | +| `response.output_item.done` | Output item finished | +| `response.completed` | Final event with full response object and usage | + +### SSE Event Mapping + +| Concept | Anthropic | OpenAI | +|---|---|---| +| **Text streaming** | `content_block_delta` with `text_delta` | `response.output_text.delta` | +| **Tool call start** | `content_block_start` with `type: "tool_use"` | `response.output_item.added` with `type: "function_call"` | +| **Tool call args** | `content_block_delta` with `input_json_delta` | `response.function_call_arguments.delta` | +| **Tool call complete** | `content_block_stop` | `response.function_call_arguments.done` | +| **Thinking** | `content_block_delta` with `thinking_delta` | `response.reasoning_summary_text.delta` | +| **End of response** | `message_stop` | `response.completed` | + +## Session and Identity + +| | Anthropic | OpenAI | +|---|---|---| +| **Session ID location** | `metadata.user_id` field in body | `prompt_cache_key` field in body | +| **Session ID format** | `user_HASH_account_UUID_session_UUID` (36-char hex UUID) | `ses_XXXX` (alphanumeric, ~30 chars) | +| **Also in headers** | No | `Session_id` header (same value as `prompt_cache_key`) | +| **Client identifier** | `anthropic-version` header, User-Agent | `Originator` header (e.g. `opencode`), User-Agent | + +## Tool Names + +Tool names differ in casing between providers. Anthropic uses PascalCase, OpenAI uses lowercase. + +| Function | Anthropic (Claude Code) | OpenAI (OpenCode) | +|---|---|---| +| Read file | `Read` | `read` | +| Edit file | `Edit` | `apply_patch` | +| Write file | `Write` | (via `apply_patch`) | +| Run command | `Bash` | `bash` | +| Search content | `Grep` | `grep` | +| Find files | `Glob` | `glob` | +| Spawn subagent | `Agent` | `task` | +| Ask user | `AskUserQuestion` | `question` | +| Web fetch | `WebFetch` | `webfetch` | +| Web search | `WebSearch` | (not observed) | +| Todo list | `TodoWrite` | `todowrite` | +| Skills/commands | `Skill` | `skill` | +| Tool discovery | `ToolSearch` | (not observed) | +| Notebook | `NotebookEdit` | (not observed) | + +## Subagent / Task Spawning + +| | Anthropic | OpenAI | +|---|---|---| +| **Tool name** | `Agent` | `task` | +| **How it works** | Agent tool call with `prompt` and `description` fields | Task tool call with `prompt` and `description` fields | +| **Session sharing** | Subagent shares the same session UUID as parent | Subagent gets its own `prompt_cache_key` | +| **Parent-child link** | Same session ID; distinguished by system prompt length (main >10K, subagent ~4-5K) | `function_call_output` contains `task_id: ses_XXX` referencing the subagent's session | +| **Classification** | System prompt length threshold | Presence of management tools (`task`, `question`, `todowrite`) indicates main | + +## Thread Classification Heuristics + +Used by greyproxy to distinguish main conversations from subagents and utilities. + +### Anthropic + +Based on system prompt length (`system[]` blocks total character count): + +| System Prompt Length | Tools | Classification | +|---|---|---| +| > 10,000 chars | Any | `main` (Claude Code primary conversation) | +| > 1,000 chars | Any | `subagent` | +| > 100 chars | <= 2 | `mcp` (MCP utility, discarded) | +| <= 100 chars | Any | `utility` (discarded) | + +### OpenAI + +Based on tool list contents (system prompt length is identical for main and subagents): + +| Condition | Classification | +|---|---| +| Tools include `task`, `question`, or `todowrite` | `main` (OpenCode primary conversation) | +| Has tools but no management tools | `subagent` | +| No tools | `utility` (e.g. title generator using gpt-5-nano) | + +## Usage / Token Reporting + +| Field | Anthropic | OpenAI | +|---|---|---| +| **Location** | `message_delta` event and `message_start` | `response.completed` event -> `response.usage` | +| **Input tokens** | `usage.input_tokens` | `usage.input_tokens` | +| **Output tokens** | `usage.output_tokens` | `usage.output_tokens` | +| **Cache tokens** | `usage.cache_read_input_tokens`, `usage.cache_creation_input_tokens` | `usage.input_tokens_details.cached_tokens` | +| **Thinking tokens** | Not separately reported | `usage.output_tokens_details.reasoning_tokens` | diff --git a/docs/websocket-support.md b/docs/websocket-support.md new file mode 100644 index 0000000..310c139 --- /dev/null +++ b/docs/websocket-support.md @@ -0,0 +1,187 @@ +# WebSocket MITM Support + +This document tracks the engineering journey of getting WebSocket traffic (specifically `codex` CLI traffic to `api.openai.com/v1/responses`) captured and decoded by greyproxy. + +--- + +## Background + +The OpenAI `/v1/responses` endpoint uses WebSocket (`Upgrade: websocket`) instead of HTTP chunked streaming. When `codex` CLI sends requests through the proxy, they go over SOCKS5, then MITM TLS termination, then HTTP — so the 101 Switching Protocols response redirects the connection into WebSocket territory that greyproxy did not previously handle. + +The WebSocket connection also uses the `permessage-deflate` extension, which compresses each message using raw DEFLATE. This added a second layer of complexity on top of the WebSocket framing. + +--- + +## Issue History + +### Issue 1: 101 Switching Protocols exits `httpRoundTrip` before hooks fire + +**Symptom:** No traffic from `api.openai.com/v1/responses` appeared in the activity log. The proxy was transparently passing the WebSocket tunnel without capturing anything. + +**Root cause:** `httpRoundTrip` checked `resp.StatusCode == 200` before firing `OnHTTPRoundTrip`. A 101 response caused an early return via `handleUpgradeResponse`, bypassing the hook entirely. + +**Fix:** Added an explicit 101 branch before the main response path that fires the hook with the upgrade request/response metadata, then hands off to `handleUpgradeResponse`. This makes the 101 visible in the activity log with full request and response headers. + +--- + +### Issue 2: WebSocket frames not captured at all + +**Symptom:** Even after the 101 appeared in the UI, no frame-level content was captured. `handleUpgradeResponse` fell through to a plain `io.Copy` pipe. + +**Root cause:** `sniffing.websocket` was not set to `true` in the service metadata config, so `h.Websocket` was false and `handleUpgradeResponse` skipped the `sniffingWebsocketFrame` path. + +**Fix:** Added `sniffing.websocket: true` to both HTTP and SOCKS5 service metadata in `greyproxy.yml` and in the test matrix isolated config. + +--- + +### Issue 3: Captured WebSocket frame payloads were binary (masked, not unmasked) + +**Symptom:** Frames were being captured and stored in the DB, but the `request_body` column contained binary garbage rather than readable JSON. + +**Root cause (part 1 — masking):** RFC 6455 requires client→server frames to be XOR-masked with a 4-byte key. In `copyWebsocketFrame`, the in-place XOR was done on `buf.Bytes()` directly: + +```go +payload := buf.Bytes() // slice into buf's backing array +payload[i] ^= mask[i%4] // modifies buf in-place! +``` + +Then the forwarding used: +```go +fr.Data = io.MultiReader(bytes.NewReader(buf.Bytes()), fr.Data) +``` + +Because `buf.Bytes()` was modified in-place, the forwarded bytes were already unmasked but the frame header still declared `Masked=true` with the original key. The server XOR'd the data again, producing garbage. `codex` detected the corrupted responses and fell back from WebSocket to plain HTTP after 5 attempts. + +**Fix:** Make an independent copy before unmasking, so the captured payload is plaintext but the forwarded wire bytes remain correctly masked: + +```go +payload := make([]byte, buf.Len()) +copy(payload, buf.Bytes()) // independent copy +for i := range payload { + payload[i] ^= mask[i%4] // unmask the copy only +} +``` + +--- + +### Issue 4: permessage-deflate frames could not be decompressed (shared context) + +**Symptom:** After the masking fix, WebSocket traffic stayed connected and frames were captured with `rsv1=true`. Decompression failed with `unexpected EOF` or `invalid code lengths set`. Server returned `Sec-Websocket-Extensions: permessage-deflate` (no `no_context_takeover`). + +**Root cause:** `permessage-deflate` by default uses a shared DEFLATE context across all frames in a session. Each frame's compressed bytes are a continuation of the previous frame's DEFLATE stream. Decompressing each frame independently (with a fresh `flate.NewReader`) fails for any frame after the first. + +**Fix:** Rewrite `Sec-Websocket-Extensions` in the WebSocket upgrade request (before sending to the upstream server) to force no-context-takeover on both sides: + +```go +if strings.EqualFold(req.Header.Get("Upgrade"), "websocket") { + ext := req.Header.Get("Sec-Websocket-Extensions") + if strings.Contains(strings.ToLower(ext), "permessage-deflate") { + req.Header.Set("Sec-Websocket-Extensions", + "permessage-deflate; client_no_context_takeover; server_no_context_takeover") + } +} +``` + +OpenAI's server accepted this and responded with `permessage-deflate; server_no_context_takeover; client_no_context_takeover`. Each frame is now independently decompressible. + +--- + +### Issue 5: Go's `compress/flate` returns `unexpected EOF` on valid DEFLATE frames + +**Symptom:** Even after forcing `no_context_takeover`, decompression still failed with `unexpected EOF` for every frame. Python's `zlib.decompressobj(-15)` successfully decompressed the same bytes. + +**Root cause:** The `permessage-deflate` spec requires senders to strip the 4-byte SYNC_FLUSH trailer (`\x00\x00\xff\xff`) from the end of each frame's compressed payload. Receivers are expected to re-append it before decompressing. The initial `decompressWebSocketFrame` did this: + +```go +payload = append(payload, 0x00, 0x00, 0xff, 0xff) +r := flate.NewReader(bytes.NewReader(payload)) +``` + +However, the SYNC_FLUSH block has `BFINAL=0` (not the last block). Go's pure-Go `compress/flate` implementation requires a `BFINAL=1` block to signal end-of-stream and return clean `io.EOF`. Python's `libz` handles `BFINAL=0` SYNC_FLUSH implicitly. + +**Fix (gorilla/websocket technique):** Append both the SYNC_FLUSH trailer AND an additional empty `BFINAL=1` stored block: + +```go +const tail = "\x00\x00\xff\xff\x01\x00\x00\xff\xff" +// ^^^^^^^^^^^^^^^^ SYNC_FLUSH (stripped by sender) +// ^^^^^^^^^^^^^^^^^^^^^^^^ BFINAL=1 empty block (Go needs this) +mr := io.MultiReader(bytes.NewReader(payload), strings.NewReader(tail)) +r := flate.NewReader(mr) +``` + +`\x01\x00\x00\xff\xff` = BFINAL=1, BTYPE=00 (non-compressed), LEN=0, NLEN=0xFFFF. This makes Go's flate reader terminate cleanly. + +--- + +### Issue 6: Conversation assembler crashes on WS_REQ/WS_RESP rows (NULL `response_content_type`) + +**Symptom:** After WebSocket frames were stored as `WS_REQ`/`WS_RESP` transactions, the conversation assembler logged 27+ warnings: + +``` +WARN assembler: failed to scan transaction row error="sql: Scan error on column index 8, +name \"response_content_type\": converting NULL to string is unsupported" +``` + +The assembler skipped every WS transaction, so no WebSocket sessions appeared in the conversation view. + +**Root cause:** WS frame rows have `response_content_type = NULL` (they have no HTTP response, only a WebSocket payload). The assembler scanned this column into a `string` variable, which Go's `database/sql` refuses to do for NULL values. + +**Fix:** Changed the scan variable type from `string` to `*string` and added a `derefString` helper that returns `""` for nil pointers. + +--- + +## Current State + +| Capability | Status | +|---|---| +| 101 Switching Protocols logged | ✅ | +| WebSocket frames captured | ✅ | +| Client→server frames correctly forwarded (masking preserved) | ✅ | +| permessage-deflate frames decompressed | ✅ | +| no_context_takeover negotiated transparently | ✅ | +| WS frames visible in activity log as `WS_REQ`/`WS_RESP` | ✅ | +| Conversation assembler handles WS rows without crashing | ✅ | +| Conversation assembly from WebSocket sessions | work in progress | + +--- + +## Architecture Notes + +### Frame capture flow + +``` +Client (codex) + │ [SOCKS5] + ▼ +greyproxy SOCKS5 listener + │ [MITM TLS termination] + ▼ +httpRoundTrip (sniffer.go) + │ GET /v1/responses → 101 + │ [rewrites Sec-Websocket-Extensions before req.Write to force no_context_takeover] + ▼ +handleUpgradeResponse → sniffingWebsocketFrame + │ two goroutines: client→server and server→client + ▼ +copyWebsocketFrame (per frame) + │ 1. fr.ReadFrom(r) — reads frame header + LimitReader for body + │ 2. io.Copy(buf, fr.Data) — drains body into buffer + │ 3. make+copy → payload — independent copy for hook + │ 4. XOR unmask payload — client frames only (Masked=true) + │ 5. GlobalWebSocketFrameHook — fires with unmasked payload + │ 6. fr.Data = MultiReader(buf.Bytes(), fr.Data) — reassemble masked wire bytes + │ 7. fr.WriteTo(w) — forward original masked frame + ▼ +program.go hook goroutine + │ if RSV1: decompressWebSocketFrame (append 9-byte tail, flate.NewReader) + ▼ +greyproxy.CreateHttpTransaction (WS_REQ or WS_RESP) +``` + +### Compression tail breakdown + +``` +Bytes appended before decompression: + 00 00 FF FF — SYNC_FLUSH terminator (stripped by RFC 7692) + 01 00 00 FF FF — BFINAL=1 empty stored block (required by Go's flate) +``` diff --git a/greyproxy.yml b/greyproxy.yml index 72b06d7..ea792d6 100644 --- a/greyproxy.yml +++ b/greyproxy.yml @@ -34,6 +34,8 @@ services: auther: auther-0 metadata: sniffing: true + sniffing.websocket: true + mitm.alpn: "http/1.1" listener: type: tcp admission: admission-0 @@ -49,6 +51,8 @@ services: auther: auther-0 metadata: sniffing: true + sniffing.websocket: true + mitm.alpn: "http/1.1" listener: type: tcp admission: admission-0 diff --git a/internal/gostx/internal/util/sniffing/sniffer.go b/internal/gostx/internal/util/sniffing/sniffer.go index cdf34db..92a4da7 100644 --- a/internal/gostx/internal/util/sniffing/sniffer.go +++ b/internal/gostx/internal/util/sniffing/sniffer.go @@ -124,6 +124,21 @@ type HTTPRoundTripInfo struct { // Set this from program initialization to record transactions to the database. var GlobalHTTPRoundTripHook func(info HTTPRoundTripInfo) +// WebSocketFrameInfo contains a single WebSocket frame captured during MITM. +type WebSocketFrameInfo struct { + Host string + URI string + From string // "client" or "server" + OpCode int + Fin bool + Rsv1 bool // true when permessage-deflate compression is active + Payload []byte + ContainerName string +} + +// GlobalWebSocketFrameHook is called (if set) after each MITM-intercepted WebSocket frame. +var GlobalWebSocketFrameHook func(info WebSocketFrameInfo) + // ErrRequestDenied is returned by the hold hook to indicate the request should be denied. var ErrRequestDenied = errors.New("request denied") @@ -510,6 +525,18 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser, subInfo = credSub(req) } + // For WebSocket upgrades, rewrite Sec-Websocket-Extensions to force no-context-takeover + // on both sides. This ensures each compressed frame can be decompressed independently, + // matching the behaviour of mitmproxy. Without this, permessage-deflate uses a shared + // deflate context across frames, making per-frame decompression impossible. + if strings.EqualFold(req.Header.Get("Upgrade"), "websocket") { + ext := req.Header.Get("Sec-Websocket-Extensions") + if strings.Contains(strings.ToLower(ext), "permessage-deflate") { + req.Header.Set("Sec-Websocket-Extensions", + "permessage-deflate; client_no_context_takeover; server_no_context_takeover") + } + } + err = req.Write(cc) if reqBody != nil { @@ -551,6 +578,31 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser, } if resp.StatusCode == http.StatusSwitchingProtocols { + // Fire the hook so the 101 response is visible in the activity log + // before the connection is handed off to the WebSocket frame handler. + if h.OnHTTPRoundTrip != nil || GlobalHTTPRoundTripHook != nil { + containerName := string(xctx.ClientIDFromContext(ctx)) + if containerName == "" { + containerName = ro.ClientID + } + info := HTTPRoundTripInfo{ + Host: req.Host, + Method: req.Method, + URI: req.RequestURI, + Proto: req.Proto, + StatusCode: resp.StatusCode, + RequestHeaders: ro.HTTP.Request.Header, + ResponseHeaders: resp.Header, + ContainerName: containerName, + DurationMs: time.Since(ro.Time).Milliseconds(), + } + if h.OnHTTPRoundTrip != nil { + h.OnHTTPRoundTrip(info) + } + if GlobalHTTPRoundTripHook != nil { + GlobalHTTPRoundTripHook(info) + } + } h.handleUpgradeResponse(ctx, rw, cc, req, resp, ro, log) return } @@ -668,6 +720,8 @@ func (h *Sniffer) sniffingWebsocketFrame(ctx context.Context, rw, cc io.ReadWrit sampleRate = math.MaxFloat64 } + containerName := string(xctx.ClientIDFromContext(ctx)) + go func() { ro2 := &xrecorder.HandlerRecorderObject{} *ro2 = *ro @@ -679,7 +733,7 @@ func (h *Sniffer) sniffingWebsocketFrame(ctx context.Context, rw, cc io.ReadWrit for { start := time.Now() - if err := h.copyWebsocketFrame(cc, rw, buf, "client", ro); err != nil { + if err := h.copyWebsocketFrame(cc, rw, buf, "client", ro, containerName); err != nil { errc <- err return } @@ -705,7 +759,7 @@ func (h *Sniffer) sniffingWebsocketFrame(ctx context.Context, rw, cc io.ReadWrit for { start := time.Now() - if err := h.copyWebsocketFrame(rw, cc, buf, "server", ro); err != nil { + if err := h.copyWebsocketFrame(rw, cc, buf, "server", ro, containerName); err != nil { errc <- err return } @@ -724,7 +778,7 @@ func (h *Sniffer) sniffingWebsocketFrame(ctx context.Context, rw, cc io.ReadWrit return nil } -func (h *Sniffer) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Buffer, from string, ro *xrecorder.HandlerRecorderObject) (err error) { +func (h *Sniffer) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Buffer, from string, ro *xrecorder.HandlerRecorderObject, containerName string) (err error) { fr := ws_util.Frame{} if _, err = fr.ReadFrom(r); err != nil { return err @@ -741,10 +795,11 @@ func (h *Sniffer) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Buffer MaskKey: fr.Header.MaskKey, Length: fr.Header.PayloadLength, } - if opts := h.RecorderOptions; opts != nil && opts.HTTPBody { - bodySize := opts.MaxBodySize - if bodySize <= 0 { - bodySize = DefaultBodySize + capturePayload := (h.RecorderOptions != nil && h.RecorderOptions.HTTPBody) || h.OnHTTPRoundTrip != nil || GlobalHTTPRoundTripHook != nil || GlobalWebSocketFrameHook != nil + if capturePayload { + bodySize := DefaultBodySize + if opts := h.RecorderOptions; opts != nil && opts.MaxBodySize > 0 { + bodySize = opts.MaxBodySize } if bodySize > MaxBodySize { bodySize = MaxBodySize @@ -754,10 +809,48 @@ func (h *Sniffer) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Buffer if _, err := io.Copy(buf, io.LimitReader(fr.Data, int64(bodySize))); err != nil { return err } - ws.Payload = buf.Bytes() + // Make an independent copy before any unmasking: buf.Bytes() is the backing + // array that will be re-used for forwarding via fr.Data below. Mutating it + // in-place would send unmasked bytes to the server (still claiming Masked=true), + // causing the server to XOR the already-unmasked data → garbage. + payload := make([]byte, buf.Len()) + copy(payload, buf.Bytes()) + + // Client→server frames are masked (RFC 6455 §5.3). Unmask the copy so the + // captured payload is readable plaintext, not XOR-scrambled bytes. + if fr.Header.Masked { + mask := [4]byte{ + byte(fr.Header.MaskKey), + byte(fr.Header.MaskKey >> 8), + byte(fr.Header.MaskKey >> 16), + byte(fr.Header.MaskKey >> 24), + } + for i := range payload { + payload[i] ^= mask[i%4] + } + } + + ws.Payload = payload } ro.Websocket = ws + + if GlobalWebSocketFrameHook != nil && ws.Payload != nil { + uri := "" + if ro.HTTP != nil { + uri = ro.HTTP.URI + } + GlobalWebSocketFrameHook(WebSocketFrameInfo{ + Host: ro.Host, + URI: uri, + From: from, + OpCode: int(fr.Header.OpCode), + Fin: fr.Header.Fin, + Rsv1: fr.Header.Rsv1, + Payload: ws.Payload, + ContainerName: containerName, + }) + } length := uint64(fr.Header.Length()) + uint64(fr.Header.PayloadLength) if from == "client" { ro.InputBytes = length diff --git a/internal/gostx/mitm_hook.go b/internal/gostx/mitm_hook.go index 0da048c..8dfb8db 100644 --- a/internal/gostx/mitm_hook.go +++ b/internal/gostx/mitm_hook.go @@ -101,6 +101,38 @@ func SetGlobalCredentialSubstituter(hook func(req *http.Request) *CredentialSubs sniffing.SetGlobalCredentialSubstituter(hook) } +// MitmWebSocketFrameInfo contains a single WebSocket frame captured during MITM. +type MitmWebSocketFrameInfo struct { + Host string + URI string + From string // "client" or "server" + OpCode int + Fin bool + Rsv1 bool // true when permessage-deflate compression is active + Payload []byte + ContainerName string +} + +// SetGlobalMitmWebSocketFrameHook sets a global callback that fires after each MITM-intercepted WebSocket frame. +func SetGlobalMitmWebSocketFrameHook(hook func(info MitmWebSocketFrameInfo)) { + if hook == nil { + sniffing.GlobalWebSocketFrameHook = nil + return + } + sniffing.GlobalWebSocketFrameHook = func(info sniffing.WebSocketFrameInfo) { + hook(MitmWebSocketFrameInfo{ + Host: info.Host, + URI: info.URI, + From: info.From, + OpCode: info.OpCode, + Fin: info.Fin, + Rsv1: info.Rsv1, + Payload: info.Payload, + ContainerName: info.ContainerName, + }) + } +} + // SetGlobalMitmHoldHook sets a global callback that fires BEFORE forwarding a MITM-intercepted // HTTP request upstream. Return nil to allow, ErrRequestDenied to deny with 403. // The hook may block (e.g., waiting for user approval). diff --git a/internal/greyproxy/conversation_assembler.go b/internal/greyproxy/conversation_assembler.go index ca27673..8272935 100644 --- a/internal/greyproxy/conversation_assembler.go +++ b/internal/greyproxy/conversation_assembler.go @@ -19,7 +19,7 @@ import ( // that requires reprocessing existing conversations (e.g. new fields, linking). // When the stored version differs from this constant, the settings page // offers a "Rebuild conversations" action. -const AssemblerVersion = 4 +const AssemblerVersion = 5 // ConversationAssembler subscribes to EventTransactionNew and reassembles // LLM conversations from HTTP transactions using registered dissectors. @@ -170,6 +170,18 @@ func (a *ConversationAssembler) processNewTransactionsLocked() { return } + // For OpenAI: main sessions may reference subagent sessions via task_id + // in tool results. Load those referenced sessions too so that + // remapOpenAISubagents can remap them under their parent. + if extraSessions := extractReferencedSubagentSessions(allTxns, affectedSessions); len(extraSessions) > 0 { + extraTxns, err := a.loadTransactionsForSessions(extraSessions) + if err != nil { + slog.Warn("assembler: failed to load referenced subagent sessions", "error", err) + } else { + allTxns = append(allTxns, extraTxns...) + } + } + // Group by session and assemble sessions := groupBySession(allTxns) var allConversations []assembledConversation @@ -182,6 +194,8 @@ func (a *ConversationAssembler) processNewTransactionsLocked() { linkSubagentConversations(allConversations) // Upsert into database + upserted := 0 + upsertLastLog := time.Now() for _, conv := range allConversations { if err := a.upsertConversation(conv); err != nil { slog.Warn("assembler: failed to upsert conversation", "id", conv.conversationID, "error", err) @@ -191,6 +205,11 @@ func (a *ConversationAssembler) processNewTransactionsLocked() { Type: EventConversationUpdated, Data: map[string]any{"conversation_id": conv.conversationID}, }) + upserted++ + if now := time.Now(); now.Sub(upsertLastLog) >= 10*time.Second { + slog.Info("assembler: upserting conversations", "progress", fmt.Sprintf("%d/%d", upserted, len(allConversations))) + upsertLastLog = now + } } SetConversationProcessingState(a.db, "last_processed_id", strconv.FormatInt(maxID, 10)) @@ -247,6 +266,10 @@ type assembledTurn struct { // --- Transaction loading --- func (a *ConversationAssembler) loadNewTransactions(sinceID int64) ([]transactionEntry, int64, error) { + var totalRows int + _ = a.db.ReadDB().QueryRow(`SELECT COUNT(*) FROM http_transactions WHERE id > ?`, sinceID).Scan(&totalRows) + slog.Info("assembler: starting scan", "transactions_to_scan", totalRows) + rows, err := a.db.ReadDB().Query(` SELECT id, timestamp, container_name, url, method, destination_host, request_body, response_body, response_content_type, duration_ms @@ -260,23 +283,35 @@ func (a *ConversationAssembler) loadNewTransactions(sinceID int64) ([]transactio var entries []transactionEntry maxID := sinceID + scanned := 0 + matched := 0 + lastLog := time.Now() for rows.Next() { var ( id int64 ts, container, url, method, host string reqBody, respBody []byte - respCT string - durationMs int64 + respCT *string + durationMsPtr *int64 ) if err := rows.Scan(&id, &ts, &container, &url, &method, &host, - &reqBody, &respBody, &respCT, &durationMs); err != nil { + &reqBody, &respBody, &respCT, &durationMsPtr); err != nil { slog.Warn("assembler: failed to scan transaction row", "error", err) continue } + var durationMs int64 + if durationMsPtr != nil { + durationMs = *durationMsPtr + } + scanned++ if id > maxID { maxID = id } + if now := time.Now(); now.Sub(lastLog) >= 10*time.Second { + slog.Info("assembler: scanning transactions", "scanned", scanned, "matched", matched, "current_id", id) + lastLog = now + } d := dissector.FindDissector(url, method, host) if d == nil { @@ -290,7 +325,7 @@ func (a *ConversationAssembler) loadNewTransactions(sinceID int64) ([]transactio Host: host, RequestBody: reqBody, ResponseBody: respBody, - ResponseCT: respCT, + ResponseCT: derefString(respCT), ContainerName: container, DurationMs: durationMs, }) @@ -306,6 +341,7 @@ func (a *ConversationAssembler) loadNewTransactions(sinceID int64) ([]transactio } } + matched++ entries = append(entries, transactionEntry{ txnID: id, timestamp: ts, @@ -319,11 +355,19 @@ func (a *ConversationAssembler) loadNewTransactions(sinceID int64) ([]transactio durationMs: durationMs, }) } + if scanned > 0 { + slog.Info("assembler: scan complete", "scanned", scanned, "matched", matched) + } return entries, maxID, nil } func (a *ConversationAssembler) loadTransactionsForSessions(sessionIDs map[string]bool) ([]transactionEntry, error) { - // Build LIKE clauses for session ID filtering + // Build LIKE clauses for session ID filtering. + // The LIKE query is a pre-filter to avoid scanning all transactions. + // The actual filtering happens post-extraction: we only keep entries + // whose dissector-extracted SessionID is in our target set. This prevents + // cross-contamination when one provider's transactions mention another + // provider's session IDs (e.g. in tool results or status reports). var likeClauses []string var args []any for sid := range sessionIDs { @@ -338,7 +382,8 @@ func (a *ConversationAssembler) loadTransactionsForSessions(sessionIDs map[strin SELECT id, timestamp, container_name, url, method, destination_host, request_body, response_body, response_content_type, duration_ms FROM http_transactions - WHERE url LIKE '%%api.anthropic.com/v1/messages%%' + WHERE (url LIKE '%%api.anthropic.com/v1/messages%%' + OR url LIKE '%%api.openai.com/v1/responses%%') AND (%s) ORDER BY id`, strings.Join(likeClauses, " OR ")) @@ -354,14 +399,18 @@ func (a *ConversationAssembler) loadTransactionsForSessions(sessionIDs map[strin id int64 ts, container, url, method, host string reqBody, respBody []byte - respCT string - durationMs int64 + respCT *string + durationMsPtr *int64 ) if err := rows.Scan(&id, &ts, &container, &url, &method, &host, - &reqBody, &respBody, &respCT, &durationMs); err != nil { + &reqBody, &respBody, &respCT, &durationMsPtr); err != nil { slog.Warn("assembler: failed to scan session transaction row", "error", err) continue } + var durationMs int64 + if durationMsPtr != nil { + durationMs = *durationMsPtr + } d := dissector.FindDissector(url, method, host) if d == nil { @@ -375,7 +424,7 @@ func (a *ConversationAssembler) loadTransactionsForSessions(sessionIDs map[strin Host: host, RequestBody: reqBody, ResponseBody: respBody, - ResponseCT: respCT, + ResponseCT: derefString(respCT), ContainerName: container, DurationMs: durationMs, }) @@ -383,6 +432,14 @@ func (a *ConversationAssembler) loadTransactionsForSessions(sessionIDs map[strin continue } + // Only keep entries whose extracted session ID is in our target set. + // The LIKE query is just a pre-filter; a transaction might match + // because it *mentions* a session ID (e.g. in a tool result) rather + // than *belonging* to that session. + if result.SessionID == "" || !sessionIDs[result.SessionID] { + continue + } + var body map[string]any if len(reqBody) > 0 { if err := json.Unmarshal(reqBody, &body); err != nil { @@ -536,9 +593,138 @@ func groupBySession(txns []transactionEntry) map[string][]transactionEntry { } } } + + // For OpenAI: remap orphan subagent sessions under their parent main session. + // OpenCode spawns subagents with separate session IDs (prompt_cache_key). + // The link is in the main session's "task" tool output: "task_id: ses_XXX". + remapOpenAISubagents(sessions) + return sessions } +// remapOpenAISubagents finds OpenAI main sessions that reference subagent +// session IDs via the "task" tool, and remaps those subagent sessions to be +// children of the main session (using the sid/subagent_N convention). +func remapOpenAISubagents(sessions map[string][]transactionEntry) { + // Find main OpenAI sessions and extract task_id references + type mainInfo struct { + sid string + taskSIDs []string // subagent session IDs referenced by task tool + } + var mains []mainInfo + + for sid, entries := range sessions { + if strings.Contains(sid, "/") { + continue // already a subagent key + } + // Check if this is an OpenAI main session + isOpenAI := false + for _, e := range entries { + if e.result != nil && e.result.Provider == "openai" { + isOpenAI = true + break + } + } + if !isOpenAI { + continue + } + + // Scan for task_id references in function_call_output items + var taskSIDs []string + for _, e := range entries { + if e.result == nil { + continue + } + for _, msg := range e.result.Messages { + for _, cb := range msg.Content { + if cb.Type == "tool_result" && cb.Content != "" { + if tid := extractTaskID(cb.Content); tid != "" { + taskSIDs = append(taskSIDs, tid) + } + } + } + } + // Also check SSE response for task tool results + if e.result.SSEResponse != nil { + for _, tc := range e.result.SSEResponse.ToolCalls { + if tc.ResultPreview != "" { + if tid := extractTaskID(tc.ResultPreview); tid != "" { + taskSIDs = append(taskSIDs, tid) + } + } + } + } + } + if len(taskSIDs) > 0 { + mains = append(mains, mainInfo{sid: sid, taskSIDs: taskSIDs}) + } + } + + // Remap subagent sessions under their parent + for _, m := range mains { + subIdx := 0 + for _, taskSID := range m.taskSIDs { + // Look for sessions keyed by this task session ID (or containing it) + for existingKey := range sessions { + // Match: the session key starts with the task session ID + baseSID := existingKey + if i := strings.Index(existingKey, "/"); i >= 0 { + baseSID = existingKey[:i] + } + if baseSID != taskSID { + continue + } + subIdx++ + newKey := fmt.Sprintf("%s/subagent_%d", m.sid, subIdx) + sessions[newKey] = sessions[existingKey] + delete(sessions, existingKey) + } + } + } +} + +// extractReferencedSubagentSessions scans dissected transaction entries for +// task_id references (OpenAI subagent session IDs) and returns any that are +// not already in the known set. +func extractReferencedSubagentSessions(entries []transactionEntry, known map[string]bool) map[string]bool { + extra := map[string]bool{} + for _, e := range entries { + if e.result == nil || e.result.Provider != "openai" { + continue + } + for _, msg := range e.result.Messages { + for _, cb := range msg.Content { + if cb.Type == "tool_result" && cb.Content != "" { + if tid := extractTaskID(cb.Content); tid != "" && !known[tid] { + extra[tid] = true + } + } + } + } + if e.result.SSEResponse != nil { + for _, tc := range e.result.SSEResponse.ToolCalls { + if tc.ResultPreview != "" { + if tid := extractTaskID(tc.ResultPreview); tid != "" && !known[tid] { + extra[tid] = true + } + } + } + } + } + return extra +} + +var taskIDPattern = regexp.MustCompile(`task_id:\s*(ses_[A-Za-z0-9_]+)`) + +// extractTaskID finds a "task_id: ses_XXX" pattern in text. +func extractTaskID(text string) string { + m := taskIDPattern.FindStringSubmatch(text) + if len(m) >= 2 { + return m[1] + } + return "" +} + func splitSessionIntoThreads(entries []transactionEntry) map[string][]transactionEntry { threads := map[string][]transactionEntry{} for _, entry := range entries { @@ -546,7 +732,7 @@ func splitSessionIntoThreads(entries []transactionEntry) map[string][]transactio threads["main"] = append(threads["main"], entry) continue } - threadType := dissector.ClassifyThread(entry.result.SystemBlocks, len(entry.result.Tools)) + threadType := dissector.ClassifyThread(entry.result.Provider, entry.result.SystemBlocks, entry.result.Tools) switch threadType { case "main": threads["main"] = append(threads["main"], entry) @@ -805,6 +991,19 @@ func buildRoundsFromMessages(messages []dissector.Message) []assembledTurn { return rounds } +// detectProvider infers the LLM provider from the transaction URLs. +func detectProvider(entries []transactionEntry) string { + for _, e := range entries { + if strings.Contains(e.url, "api.openai.com") { + return "openai" + } + if strings.Contains(e.url, "api.anthropic.com") { + return "anthropic" + } + } + return "unknown" +} + func assembleConversation(sessionID string, entries []transactionEntry) assembledConversation { sort.Slice(entries, func(i, j int) bool { if entries[i].timestamp == entries[j].timestamp { @@ -813,9 +1012,11 @@ func assembleConversation(sessionID string, entries []transactionEntry) assemble return entries[i].timestamp < entries[j].timestamp }) + provider := detectProvider(entries) + conv := assembledConversation{ conversationID: "session_" + sessionID, - provider: "anthropic", + provider: provider, containerName: entries[0].containerName, startedAt: entries[0].timestamp, endedAt: entries[len(entries)-1].timestamp, @@ -1100,7 +1301,7 @@ func linkSubagentConversations(allConvs []assembledConversation) { tcs, _ := step["tool_calls"].([]map[string]any) for _, tc := range tcs { toolName, _ := tc["tool"].(string) - if toolName == "Agent" && subIdx < len(subs) { + if (toolName == "Agent" || toolName == "task") && subIdx < len(subs) { tc["linked_conversation_id"] = subs[subIdx].conversationID subIdx++ } @@ -1205,3 +1406,10 @@ func minTime(a, b time.Time) time.Time { } return b } + +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/internal/greyproxy/dissector/anthropic.go b/internal/greyproxy/dissector/anthropic.go index 83bddfb..7397295 100644 --- a/internal/greyproxy/dissector/anthropic.go +++ b/internal/greyproxy/dissector/anthropic.go @@ -58,7 +58,7 @@ func (d *AnthropicDissector) CanHandle(url, method, host string) bool { } func (d *AnthropicDissector) Extract(input ExtractionInput) (*ExtractionResult, error) { - result := &ExtractionResult{} + result := &ExtractionResult{Provider: d.Name()} // Parse request body var body struct { @@ -268,17 +268,51 @@ func SystemPromptLength(blocks []SystemBlock) int { } // ClassifyThread determines if a request represents a main conversation, -// subagent, MCP utility, or plain utility based on system prompt size and tool count. -func ClassifyThread(systemBlocks []SystemBlock, toolCount int) string { +// subagent, MCP utility, or plain utility based on provider, system prompt +// size, and tool list. +func ClassifyThread(provider string, systemBlocks []SystemBlock, tools []Tool) string { sysLen := SystemPromptLength(systemBlocks) - if sysLen > 10000 { - return "main" + toolCount := len(tools) + + switch provider { + case "openai": + return classifyOpenAIThread(sysLen, tools) + default: + // Anthropic / generic heuristic + if sysLen > 10000 { + return "main" + } + if sysLen > 1000 { + return "subagent" + } + if sysLen > 100 && toolCount <= 2 { + return "mcp" + } + return "utility" } - if sysLen > 1000 { - return "subagent" +} + +// classifyOpenAIThread uses OpenCode-specific heuristics. +// OpenCode main conversations and subagents share the same system prompt length +// (~7700 chars), so we distinguish by tool list: the main conversation has +// management tools (task, question, todowrite) that subagents lack. +func classifyOpenAIThread(sysLen int, tools []Tool) string { + toolCount := len(tools) + + if toolCount == 0 { + return "utility" + } + + // Check for management tools that only the main conversation has + for _, t := range tools { + switch t.Name { + case "task", "question", "todowrite": + return "main" + } } - if sysLen > 100 && toolCount <= 2 { - return "mcp" + + if toolCount > 0 { + return "subagent" } return "utility" } diff --git a/internal/greyproxy/dissector/anthropic_test.go b/internal/greyproxy/dissector/anthropic_test.go index 9b04f38..7d2e379 100644 --- a/internal/greyproxy/dissector/anthropic_test.go +++ b/internal/greyproxy/dissector/anthropic_test.go @@ -161,7 +161,7 @@ func TestAnthropicExtractSystemPrompt(t *testing.T) { t.Errorf("expected system prompt >10K chars (main conversation), got %d", sysLen) } - threadType := ClassifyThread(result.SystemBlocks, len(result.Tools)) + threadType := ClassifyThread("anthropic", result.SystemBlocks, result.Tools) if threadType != "main" { t.Errorf("expected thread type 'main', got %q", threadType) } diff --git a/internal/greyproxy/dissector/dissector.go b/internal/greyproxy/dissector/dissector.go index cedfa1b..769717d 100644 --- a/internal/greyproxy/dissector/dissector.go +++ b/internal/greyproxy/dissector/dissector.go @@ -33,6 +33,7 @@ type ExtractionInput struct { // ExtractionResult contains structured data extracted from an HTTP transaction. type ExtractionResult struct { + Provider string // dissector name that produced this result (e.g. "anthropic", "openai") SessionID string Model string Messages []Message @@ -115,4 +116,5 @@ func FindDissector(url, method, host string) Dissector { func init() { Register(&AnthropicDissector{}) + Register(&OpenAIDissector{}) } diff --git a/internal/greyproxy/dissector/openai.go b/internal/greyproxy/dissector/openai.go new file mode 100644 index 0000000..6c7144e --- /dev/null +++ b/internal/greyproxy/dissector/openai.go @@ -0,0 +1,471 @@ +package dissector + +// OpenAI Responses API dissector (/v1/responses only). +// +// Analysis based on captured traffic from OpenCode (opencode/1.2.6) using +// gpt-5.1 and gpt-5-nano, March 2026. +// +// Endpoint: POST https://api.openai.com/v1/responses +// +// Request body structure: +// - model: string (e.g. "gpt-5.1", "gpt-5-nano") +// - input: array of heterogeneous items (NOT uniform {role, content} like Anthropic) +// - {role: "developer", content: string} -- system prompt +// - {role: "user", content: [{type: "input_text", text: ...}]} -- user message +// - {type: "reasoning", encrypted_content: ...} -- opaque reasoning block +// - {type: "function_call", call_id, name, arguments} -- tool invocation +// - {type: "function_call_output", call_id, output} -- tool result +// - tools: array of {type: "function", name, description, parameters, strict} +// - prompt_cache_key: string like "ses_XXX" (serves as session ID) +// - reasoning: {effort, summary} +// - stream: bool (always true in observed traffic) +// +// Response: SSE stream (text/event-stream) with events: +// - response.created, response.in_progress -- lifecycle +// - response.output_item.added -- new output item (reasoning, message, function_call) +// - response.output_text.delta / .done -- streamed text output +// - response.function_call_arguments.delta / .done -- streamed tool call args +// - response.reasoning_summary_text.delta / .done -- reasoning summary +// - response.completed -- final event with usage stats +// +// Session ID: extracted from prompt_cache_key ("ses_XXX" prefix). +// Utility requests (title generation) use gpt-5-nano, no tools, no cache key. +// +// This does NOT cover /v1/chat/completions; that will be a separate dissector +// once traffic is collected. + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" +) + +// OpenAIDissector parses OpenAI Responses API (/v1/responses) transactions. +type OpenAIDissector struct{} + +func (d *OpenAIDissector) Name() string { return "openai" } + +func (d *OpenAIDissector) CanHandle(url, method, host string) bool { + if method != "POST" { + return false + } + base := url + if i := strings.IndexByte(url, '?'); i >= 0 { + base = url[:i] + } + return base == "https://api.openai.com/v1/responses" +} + +func (d *OpenAIDissector) Extract(input ExtractionInput) (*ExtractionResult, error) { + result := &ExtractionResult{Provider: d.Name()} + + var body struct { + Model string `json:"model"` + Input []json.RawMessage `json:"input"` + Tools []struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"tools"` + PromptCacheKey string `json:"prompt_cache_key"` + } + + if len(input.RequestBody) == 0 { + return result, nil + } + if err := json.Unmarshal(input.RequestBody, &body); err != nil { + return result, nil + } + + // Session ID from prompt_cache_key + result.SessionID = body.PromptCacheKey + + // Model + result.Model = body.Model + if result.Model == "" { + result.Model = "unknown" + } + + // Tools + for _, t := range body.Tools { + result.Tools = append(result.Tools, Tool{Name: t.Name, Description: t.Description}) + } + + // Parse input items into Messages and SystemBlocks + for _, raw := range body.Input { + var probe struct { + Role string `json:"role"` + Type string `json:"type"` + Content any `json:"content"` + } + if json.Unmarshal(raw, &probe) != nil { + continue + } + + switch { + case probe.Role == "developer": + // System prompt + text := extractOpenAIText(raw) + if text != "" { + result.SystemBlocks = append(result.SystemBlocks, SystemBlock{ + Type: "text", + Text: text, + }) + } + + case probe.Role == "user": + // User message + m := Message{Role: "user"} + text := extractOpenAIInputTextBlocks(raw) + if text != "" { + m.Content = []ContentBlock{{Type: "text", Text: text}} + } + result.Messages = append(result.Messages, m) + result.MessageCount++ + + case probe.Type == "function_call": + // Tool use (part of assistant turn) + var fc struct { + CallID string `json:"call_id"` + Name string `json:"name"` + Arguments string `json:"arguments"` + } + json.Unmarshal(raw, &fc) + + cb := ContentBlock{ + Type: "tool_use", + Name: fc.Name, + ID: fc.CallID, + } + // Parse arguments for summary + var argsMap map[string]any + if json.Unmarshal([]byte(fc.Arguments), &argsMap) == nil { + cb.ToolSummary = extractOpenAIToolSummary(fc.Name, argsMap) + } + if len(fc.Arguments) > 300 { + cb.Input = fc.Arguments[:300] + } else { + cb.Input = fc.Arguments + } + + // Attach to an assistant message (create one if needed, or append to last) + result.Messages = appendToAssistant(result.Messages, cb) + result.MessageCount++ + + case probe.Type == "function_call_output": + // Tool result + var fco struct { + CallID string `json:"call_id"` + Output string `json:"output"` + } + json.Unmarshal(raw, &fco) + + content := fco.Output + if len(content) > 500 { + content = content[:500] + } + cb := ContentBlock{ + Type: "tool_result", + ToolUseID: fco.CallID, + Content: content, + } + result.Messages = append(result.Messages, Message{ + Role: "user", + Content: []ContentBlock{cb}, + }) + result.MessageCount++ + + case probe.Type == "reasoning": + // Opaque reasoning block; skip (encrypted) + + case probe.Type == "message": + // Assistant message echoed back in input + var msg struct { + Role string `json:"role"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + json.Unmarshal(raw, &msg) + m := Message{Role: msg.Role} + for _, c := range msg.Content { + if c.Type == "output_text" && c.Text != "" { + m.Content = append(m.Content, ContentBlock{Type: "text", Text: c.Text}) + } + } + if len(m.Content) > 0 { + result.Messages = append(result.Messages, m) + result.MessageCount++ + } + } + } + + // Parse SSE response + if strings.Contains(input.ResponseCT, "text/event-stream") && len(input.ResponseBody) > 0 { + events := ParseSSE(string(input.ResponseBody)) + result.SSEResponse = extractOpenAIResponseFromSSE(events) + } + + return result, nil +} + +// extractOpenAIText gets the text content from a developer or user message. +// Content can be a plain string or a list of blocks. +func extractOpenAIText(raw json.RawMessage) string { + var asStr struct { + Content string `json:"content"` + } + if json.Unmarshal(raw, &asStr) == nil && asStr.Content != "" { + return asStr.Content + } + + var asList struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if json.Unmarshal(raw, &asList) == nil { + var parts []string + for _, b := range asList.Content { + if b.Text != "" { + parts = append(parts, b.Text) + } + } + return strings.Join(parts, "\n") + } + return "" +} + +// extractOpenAIInputTextBlocks extracts text from user messages with input_text blocks. +func extractOpenAIInputTextBlocks(raw json.RawMessage) string { + var msg struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if json.Unmarshal(raw, &msg) == nil { + var parts []string + for _, b := range msg.Content { + if (b.Type == "input_text" || b.Type == "text") && b.Text != "" { + parts = append(parts, b.Text) + } + } + return strings.Join(parts, "\n") + } + + // Fallback: plain string content + var plain struct { + Content string `json:"content"` + } + if json.Unmarshal(raw, &plain) == nil { + return plain.Content + } + return "" +} + +// appendToAssistant appends a tool_use block to the last assistant message, +// or creates a new assistant message if the last one isn't an assistant. +func appendToAssistant(messages []Message, cb ContentBlock) []Message { + if len(messages) > 0 && messages[len(messages)-1].Role == "assistant" { + messages[len(messages)-1].Content = append(messages[len(messages)-1].Content, cb) + return messages + } + return append(messages, Message{ + Role: "assistant", + Content: []ContentBlock{cb}, + }) +} + +// extractOpenAIResponseFromSSE builds an SSEResponseData from OpenAI SSE events. +func extractOpenAIResponseFromSSE(events []SSEEvent) *SSEResponseData { + if len(events) == 0 { + return nil + } + + var textParts []string + var toolCalls []ToolCall + + // Track function calls by output_index to build complete tool calls + type pendingFC struct { + name string + toolUseID string + } + pendingFCs := map[int]pendingFC{} + + for _, evt := range events { + var data map[string]any + if json.Unmarshal([]byte(evt.Data), &data) != nil { + continue + } + + switch evt.Event { + case "response.output_text.delta": + if delta, ok := data["delta"].(string); ok { + textParts = append(textParts, delta) + } + + case "response.output_item.added": + item, ok := data["item"].(map[string]any) + if !ok { + continue + } + itemType, _ := item["type"].(string) + outIdx := int(getFloat(data, "output_index")) + + if itemType == "function_call" { + name, _ := item["name"].(string) + callID, _ := item["call_id"].(string) + if name == "" { + name = "unknown" + } + pendingFCs[outIdx] = pendingFC{name: name, toolUseID: callID} + toolCalls = append(toolCalls, ToolCall{ + Tool: name, + ToolUseID: callID, + }) + } + + case "response.function_call_arguments.done": + args, _ := data["arguments"].(string) + itemID, _ := data["item_id"].(string) + // Find and update the matching tool call + for i := range toolCalls { + if toolCalls[i].ToolUseID != "" { + // Match by item_id prefix (fc_ IDs map to call_ IDs) + // Or just update the last one with matching tool + if matchToolCallID(toolCalls[i].ToolUseID, itemID) || i == len(toolCalls)-1 { + if len(args) > 200 { + toolCalls[i].InputPreview = args[:200] + } else { + toolCalls[i].InputPreview = args + } + // Generate summary from parsed args + var argsMap map[string]any + if json.Unmarshal([]byte(args), &argsMap) == nil { + toolCalls[i].ToolSummary = extractOpenAIToolSummary(toolCalls[i].Tool, argsMap) + } + break + } + } + } + } + } + + if len(textParts) == 0 && len(toolCalls) == 0 { + return nil + } + + result := &SSEResponseData{} + if len(textParts) > 0 { + result.Text = strings.Join(textParts, "") + } + if len(toolCalls) > 0 { + result.ToolCalls = toolCalls + } + return result +} + +func getFloat(m map[string]any, key string) float64 { + if v, ok := m[key].(float64); ok { + return v + } + return 0 +} + +// matchToolCallID checks if a call_id and an item fc_ id refer to the same call. +// OpenAI uses call_XXX in input and fc_XXX in SSE events for the same logical call. +// We match by checking if both exist (exact match is rare across formats). +func matchToolCallID(callID, itemID string) bool { + // In response SSE, item_id is fc_XXX while call_id from the request is call_XXX + // Within a single response, they correlate by output_index order. + // Simple heuristic: just match the last tool call added. + return callID != "" && itemID != "" +} + +// extractOpenAIToolSummary produces a human-readable summary from tool arguments. +// OpenAI tool names are lowercase versions of the same tools (bash, read, glob, etc.). +func extractOpenAIToolSummary(toolName string, input map[string]any) string { + str := func(key string) string { + if v, ok := input[key].(string); ok { + return v + } + return "" + } + + switch toolName { + case "read", "Read": + if fp := str("file_path"); fp != "" { + dir := filepath.Base(filepath.Dir(fp)) + base := filepath.Base(fp) + if dir != "." && dir != "/" { + return dir + "/" + base + } + return base + } + case "apply_patch": + if p := str("patch"); p != "" { + // Try to extract filename from patch + for _, line := range strings.SplitN(p, "\n", 5) { + if strings.HasPrefix(line, "*** ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + return filepath.Base(parts[1]) + } + } + } + if len(p) > 60 { + return p[:60] + "..." + } + return p + } + case "bash", "Bash": + if desc := str("description"); desc != "" { + return desc + } + if cmd := str("command"); cmd != "" { + if len(cmd) > 80 { + return cmd[:80] + "..." + } + return cmd + } + case "grep", "Grep": + if pat := str("pattern"); pat != "" { + summary := "pattern: " + pat + if p := str("path"); p != "" { + summary += " in " + filepath.Base(p) + } + return summary + } + case "glob", "Glob": + if pat := str("pattern"); pat != "" { + return pat + } + case "webfetch", "WebFetch": + if u := str("url"); u != "" { + return u + } + case "skill", "Skill": + if name := str("name"); name != "" { + return name + } + } + + // Fallback: list top-level keys with short values + var parts []string + for k, v := range input { + if s, ok := v.(string); ok && len(s) <= 40 { + parts = append(parts, fmt.Sprintf("%s=%s", k, s)) + } + } + if len(parts) > 0 { + s := strings.Join(parts, " ") + if len(s) > 80 { + return s[:80] + "..." + } + return s + } + return "" +} diff --git a/internal/greyproxy/dissector/openai_test.go b/internal/greyproxy/dissector/openai_test.go new file mode 100644 index 0000000..1c7cc51 --- /dev/null +++ b/internal/greyproxy/dissector/openai_test.go @@ -0,0 +1,305 @@ +package dissector + +import ( + "testing" +) + +func TestOpenAICanHandle(t *testing.T) { + d := &OpenAIDissector{} + + tests := []struct { + url, method, host string + want bool + }{ + {"https://api.openai.com/v1/responses", "POST", "api.openai.com", true}, + {"https://api.openai.com/v1/responses?stream=true", "POST", "api.openai.com", true}, + {"https://api.openai.com/v1/responses", "GET", "api.openai.com", false}, + {"https://api.openai.com/v1/chat/completions", "POST", "api.openai.com", false}, + {"https://api.anthropic.com/v1/messages", "POST", "api.anthropic.com", false}, + } + + for _, tt := range tests { + got := d.CanHandle(tt.url, tt.method, tt.host) + if got != tt.want { + t.Errorf("CanHandle(%q, %q, %q) = %v, want %v", tt.url, tt.method, tt.host, got, tt.want) + } + } +} + +func TestOpenAIExtract(t *testing.T) { + d := &OpenAIDissector{} + + tests := []struct { + fixtureID string + wantSessionID string + wantModel string + wantMinMsgCount int + wantSystemBlocks bool + }{ + // Utility: title generator, no session, gpt-5-nano + {"openai_1163", "", "gpt-5-nano", 2, true}, + // First subagent turn: session, gpt-5.1, user + developer + {"openai_1166", "ses_2fd479e75ffepAe8R23CkidGZC", "gpt-5.1", 1, true}, + // Mid-conversation with tool calls: reasoning + function_call + function_call_output + {"openai_1170", "ses_2fd479e84ffeWtEMMxa6aH6Smi", "gpt-5.1", 3, true}, + } + + for _, tt := range tests { + t.Run("fixture_"+tt.fixtureID, func(t *testing.T) { + f := loadFixture(t, tt.fixtureID) + + result, err := d.Extract(ExtractionInput{ + TransactionID: int64(f.ID), + URL: f.URL, + Method: "POST", + Host: f.DestinationHost, + RequestBody: []byte(f.RequestBody), + ResponseBody: []byte(f.ResponseBody), + RequestCT: "application/json", + ResponseCT: f.ResponseContentType, + ContainerName: f.ContainerName, + DurationMs: f.DurationMs, + }) + if err != nil { + t.Fatalf("Extract error: %v", err) + } + + if result.SessionID != tt.wantSessionID { + t.Errorf("SessionID = %q, want %q", result.SessionID, tt.wantSessionID) + } + if result.Model != tt.wantModel { + t.Errorf("Model = %q, want %q", result.Model, tt.wantModel) + } + if result.MessageCount < tt.wantMinMsgCount { + t.Errorf("MessageCount = %d, want >= %d", result.MessageCount, tt.wantMinMsgCount) + } + if tt.wantSystemBlocks && len(result.SystemBlocks) == 0 { + t.Error("expected system blocks to be non-empty") + } + }) + } +} + +func TestOpenAIExtractSSEResponse(t *testing.T) { + d := &OpenAIDissector{} + + // 1163: simple text response (title generator) + t.Run("text_response", func(t *testing.T) { + f := loadFixture(t, "openai_1163") + result, err := d.Extract(ExtractionInput{ + TransactionID: int64(f.ID), + URL: f.URL, + Method: "POST", + Host: f.DestinationHost, + RequestBody: []byte(f.RequestBody), + ResponseBody: []byte(f.ResponseBody), + RequestCT: "application/json", + ResponseCT: f.ResponseContentType, + ContainerName: f.ContainerName, + DurationMs: f.DurationMs, + }) + if err != nil { + t.Fatalf("Extract error: %v", err) + } + if result.SSEResponse == nil { + t.Fatal("expected SSEResponse to be non-nil") + } + if result.SSEResponse.Text == "" { + t.Error("expected SSE response to have text") + } + }) + + // 1170: function call response + t.Run("function_call_response", func(t *testing.T) { + f := loadFixture(t, "openai_1170") + result, err := d.Extract(ExtractionInput{ + TransactionID: int64(f.ID), + URL: f.URL, + Method: "POST", + Host: f.DestinationHost, + RequestBody: []byte(f.RequestBody), + ResponseBody: []byte(f.ResponseBody), + RequestCT: "application/json", + ResponseCT: f.ResponseContentType, + ContainerName: f.ContainerName, + DurationMs: f.DurationMs, + }) + if err != nil { + t.Fatalf("Extract error: %v", err) + } + if result.SSEResponse == nil { + t.Fatal("expected SSEResponse to be non-nil") + } + if len(result.SSEResponse.ToolCalls) == 0 { + t.Error("expected SSE response to have tool calls") + } + }) +} + +func TestOpenAIExtractToolCalls(t *testing.T) { + d := &OpenAIDissector{} + f := loadFixture(t, "openai_1170") + + result, err := d.Extract(ExtractionInput{ + TransactionID: int64(f.ID), + URL: f.URL, + Method: "POST", + Host: f.DestinationHost, + RequestBody: []byte(f.RequestBody), + ResponseBody: []byte(f.ResponseBody), + RequestCT: "application/json", + ResponseCT: f.ResponseContentType, + ContainerName: f.ContainerName, + DurationMs: f.DurationMs, + }) + if err != nil { + t.Fatalf("Extract error: %v", err) + } + + // Should have function_call and function_call_output parsed as messages + hasToolUse := false + hasToolResult := false + for _, msg := range result.Messages { + for _, cb := range msg.Content { + if cb.Type == "tool_use" { + hasToolUse = true + if cb.Name == "" { + t.Error("tool_use block has empty Name") + } + } + if cb.Type == "tool_result" { + hasToolResult = true + } + } + } + if !hasToolUse { + t.Error("expected at least one tool_use content block from function_call items") + } + if !hasToolResult { + t.Error("expected at least one tool_result content block from function_call_output items") + } +} + +func TestOpenAIClassifyThread(t *testing.T) { + d := &OpenAIDissector{} + + // Utility: title generator (short developer prompt, no tools) + t.Run("utility", func(t *testing.T) { + f := loadFixture(t, "openai_1163") + result, err := d.Extract(ExtractionInput{ + TransactionID: int64(f.ID), + URL: f.URL, + Method: "POST", + Host: f.DestinationHost, + RequestBody: []byte(f.RequestBody), + ResponseBody: []byte(f.ResponseBody), + RequestCT: "application/json", + ResponseCT: f.ResponseContentType, + ContainerName: f.ContainerName, + DurationMs: f.DurationMs, + }) + if err != nil { + t.Fatalf("Extract error: %v", err) + } + threadType := ClassifyThread("openai", result.SystemBlocks, result.Tools) + if threadType != "utility" { + t.Errorf("expected utility for title generator (no tools), got %q", threadType) + } + }) + + // Subagent: 7 tools (no task/question/todowrite), developer prompt truncated in fixture + t.Run("subagent_fixture", func(t *testing.T) { + f := loadFixture(t, "openai_1166") + result, err := d.Extract(ExtractionInput{ + TransactionID: int64(f.ID), + URL: f.URL, + Method: "POST", + Host: f.DestinationHost, + RequestBody: []byte(f.RequestBody), + ResponseBody: []byte(f.ResponseBody), + RequestCT: "application/json", + ResponseCT: f.ResponseContentType, + ContainerName: f.ContainerName, + DurationMs: f.DurationMs, + }) + if err != nil { + t.Fatalf("Extract error: %v", err) + } + if len(result.Tools) != 7 { + t.Errorf("expected 7 tools, got %d", len(result.Tools)) + } + threadType := ClassifyThread("openai", result.SystemBlocks, result.Tools) + // Has tools but no management tools (task/question/todowrite) -> subagent + if threadType != "subagent" { + t.Errorf("expected subagent for 7-tool session without task tool, got %q", threadType) + } + }) +} + +func TestOpenAIClassifyThreadDirect(t *testing.T) { + sysBlocks := []SystemBlock{{Type: "text", Text: "You are a coding agent..."}} + + tests := []struct { + name string + tools []Tool + want string + }{ + { + "main with task tool", + []Tool{{Name: "question"}, {Name: "bash"}, {Name: "read"}, {Name: "task"}, {Name: "webfetch"}}, + "main", + }, + { + "subagent without management tools", + []Tool{{Name: "bash"}, {Name: "read"}, {Name: "glob"}, {Name: "grep"}, {Name: "webfetch"}}, + "subagent", + }, + { + "utility no tools", + nil, + "utility", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ClassifyThread("openai", sysBlocks, tt.tools) + if got != tt.want { + t.Errorf("ClassifyThread(openai) = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFindDissectorOpenAI(t *testing.T) { + d := FindDissector("https://api.openai.com/v1/responses", "POST", "api.openai.com") + if d == nil { + t.Fatal("expected to find OpenAI dissector") + } + if d.Name() != "openai" { + t.Errorf("expected dissector name 'openai', got %q", d.Name()) + } +} + +func TestOpenAIToolSummary(t *testing.T) { + tests := []struct { + name string + toolName string + input map[string]any + want string + }{ + {"bash with command", "bash", map[string]any{"command": "ls -la"}, "ls -la"}, + {"read file", "read", map[string]any{"file_path": "/home/user/src/main.go"}, "src/main.go"}, + {"webfetch", "webfetch", map[string]any{"url": "https://example.com"}, "https://example.com"}, + {"grep", "grep", map[string]any{"pattern": "TODO", "path": "/src"}, "pattern: TODO in src"}, + {"glob", "glob", map[string]any{"pattern": "**/*.go"}, "**/*.go"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractOpenAIToolSummary(tt.toolName, tt.input) + if got != tt.want { + t.Errorf("extractOpenAIToolSummary(%q, ...) = %q, want %q", tt.toolName, got, tt.want) + } + }) + } +} diff --git a/internal/greyproxy/dissector/testdata/openai_1163.json b/internal/greyproxy/dissector/testdata/openai_1163.json new file mode 100644 index 0000000..69073a9 --- /dev/null +++ b/internal/greyproxy/dissector/testdata/openai_1163.json @@ -0,0 +1,11 @@ +{ + "id": 1163, + "container_name": "opencode", + "url": "https://api.openai.com/v1/responses", + "method": "POST", + "destination_host": "api.openai.com", + "request_body": "{\"model\": \"gpt-5-nano\", \"input\": [{\"role\": \"developer\", \"content\": \"You are a title generator. You output ONLY a thread title. Nothing else.\\n\\n\\nGenerate a brief title that would help the user find this conversation later.\\n\\nFollow all rules in \\nUse the so you know what a good title looks like.\\nYour output must be:\\n- A single line\\n- \\u226450 characters\\n- No explanations\\n\\n\\n\\n- you MUST use the same language as the user message you are summarizing\\n- Title must be grammatically correct and read naturally - no word salad\\n- Never include tool names in the title (e.g. \\\"read tool\\\", \\\"bash tool\\\", \\\"edit tool\\\")\\n- Focus on the main topic or question the user needs to retrieve\\n- Vary your phrasing - avoid repetitive patterns like always starting with \\\"Analyzing\\\"\\n- When a file is mentioned, focus on WHAT the user wants to do WITH the file, n... [truncated for test]\"}, {\"role\": \"user\", \"content\": [{\"type\": \"input_text\", \"text\": \"Generate a title for this conversation:\\n\"}]}, {\"role\": \"user\", \"content\": [{\"type\": \"input_text\", \"text\": \"Hello gpt, how are you ? Can you have one subagent searching about greyhaven, another about monadical, and tell me how there are related ?\"}]}], \"max_output_tokens\": 32000, \"store\": false, \"include\": [\"reasoning.encrypted_content\"], \"reasoning\": {\"effort\": \"minimal\"}, \"stream\": true}", + "response_body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_03f6efb31b6f0bf90169bb1074517481a3ad10315cbc2bc9bf\",\"object\":\"response\",\"created_at\":1773867124,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":32000,\"max_tool_calls\":null,\"model\":\"gpt-5-nano-2025-08-07\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":\"minimal\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_03f6efb31b6f0bf90169bb1074517481a3ad10315cbc2bc9bf\",\"object\":\"response\",\"created_at\":1773867124,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":32000,\"max_tool_calls\":null,\"model\":\"gpt-5-nano-2025-08-07\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":\"minimal\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\": \"response.output_item.added\", \"item\": {\"id\": \"rs_03f6efb31b6f0bf90169bb1074d4c881a3801f88938ea5891f\", \"type\": \"reasoning\", \"encrypted_content\": \"REDACTED_FOR_TEST\", \"summary\": []}, \"output_index\": 0, \"sequence_number\": 2}\n\nevent: response.output_item.done\ndata: {\"type\": \"response.output_item.done\", \"item\": {\"id\": \"rs_03f6efb31b6f0bf90169bb1074d4c881a3801f88938ea5891f\", \"type\": \"reasoning\", \"encrypted_content\": \"REDACTED_FOR_TEST\", \"summary\": []}, \"output_index\": 0, \"sequence_number\": 3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Sub\",\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"logprobs\":[],\"obfuscation\":\"8YQOue5eOvt38\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"agent\",\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"logprobs\":[],\"obfuscation\":\"G0LJmSRDmWW\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" search\",\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"logprobs\":[],\"obfuscation\":\"pwj64UMwm\",\"output_index\":1,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\":\",\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"logprobs\":[],\"obfuscation\":\"ovVbRcVelK3jOaF\",\"output_index\":1,\"sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Grey\",\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"logprobs\":[],\"obfuscation\":\"sh7MA61Pp5T\",\"output_index\":1,\"sequence_number\":10}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"haven\",\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"logprobs\":[],\"obfuscation\":\"FPwBNqIHVm5\",\"output_index\":1,\"sequence_number\":11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" vs\",\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"logprobs\":[],\"obfuscation\":\"YKSpqbIHjeStD\",\"output_index\":1,\"sequence_number\":12}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Monad\",\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"logprobs\":[],\"obfuscation\":\"rkPVDkPzdH\",\"output_index\":1,\"sequence_number\":13}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ical\",\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"logprobs\":[],\"obfuscation\":\"gfxh4eovBCtq\",\"output_index\":1,\"sequence_number\":14}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" relations\",\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"logprobs\":[],\"obfuscation\":\"Ty7lCa\",\"output_index\":1,\"sequence_number\":15}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" exploration\",\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"logprobs\":[],\"obfuscation\":\"igCF\",\"output_index\":1,\"sequence_number\":16}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":17,\"text\":\"Subagent search: Greyhaven vs Monadical relations exploration\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Subagent search: Greyhaven vs Monadical relations exploration\"},\"sequence_number\":18}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Subagent search: Greyhaven vs Monadical relations exploration\"}],\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":19}\n\nevent: response.completed\ndata: {\"type\": \"response.completed\", \"response\": {\"id\": \"resp_03f6efb31b6f0bf90169bb1074517481a3ad10315cbc2bc9bf\", \"object\": \"response\", \"created_at\": 1773867124, \"status\": \"completed\", \"background\": false, \"completed_at\": 1773867125, \"error\": null, \"frequency_penalty\": 0.0, \"incomplete_details\": null, \"instructions\": null, \"max_output_tokens\": 32000, \"max_tool_calls\": null, \"model\": \"gpt-5-nano-2025-08-07\", \"output\": [{\"id\": \"rs_03f6efb31b6f0bf90169bb1074d4c881a3801f88938ea5891f\", \"type\": \"reasoning\", \"encrypted_content\": \"REDACTED_FOR_TEST\", \"summary\": []}, {\"id\": \"msg_03f6efb31b6f0bf90169bb1074f62081a39fed34133d5fc032\", \"type\": \"message\", \"status\": \"completed\", \"content\": [{\"type\": \"output_text\", \"annotations\": [], \"logprobs\": [], \"text\": \"Subagent search: Greyhaven vs Monadical relations exploration\"}], \"role\": \"assistant\"}], \"parallel_tool_calls\": true, \"presence_penalty\": 0.0, \"previous_response_id\": null, \"prompt_cache_key\": null, \"prompt_cache_retention\": null, \"reasoning\": {\"effort\": \"minimal\", \"summary\": null}, \"safety_identifier\": null, \"service_tier\": \"default\", \"store\": false, \"temperature\": 1.0, \"text\": {\"format\": {\"type\": \"text\"}, \"verbosity\": \"medium\"}, \"tool_choice\": \"auto\", \"tools\": [], \"top_logprobs\": 0, \"top_p\": 1.0, \"truncation\": \"disabled\", \"usage\": {\"input_tokens\": 557, \"input_tokens_details\": {\"cached_tokens\": 0}, \"output_tokens\": 29, \"output_tokens_details\": {\"reasoning_tokens\": 0}, \"total_tokens\": 586}, \"user\": null, \"metadata\": {}}, \"sequence_number\": 20}\n\n", + "response_content_type": "text/event-stream; charset=utf-8", + "duration_ms": 1493 +} \ No newline at end of file diff --git a/internal/greyproxy/dissector/testdata/openai_1166.json b/internal/greyproxy/dissector/testdata/openai_1166.json new file mode 100644 index 0000000..c526b54 --- /dev/null +++ b/internal/greyproxy/dissector/testdata/openai_1166.json @@ -0,0 +1,11 @@ +{ + "id": 1166, + "container_name": "opencode", + "url": "https://api.openai.com/v1/responses", + "method": "POST", + "destination_host": "api.openai.com", + "request_body": "{\"model\": \"gpt-5.1\", \"input\": [{\"role\": \"developer\", \"content\": \"You are OpenCode, the best coding agent on the planet.\\n\\nYou are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.\\n\\n## Editing constraints\\n- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.\\n- Only add comments if they are necessary to make a non-obvious block easier to understand.\\n- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more ef... [truncated for test]\"}, {\"role\": \"user\", \"content\": [{\"type\": \"input_text\", \"text\": \"You are a research assistant. Use web tools to research the company 'Monadical' (sometimes 'Monadical Inc.' or 'Monadical SAS'). Collect: (1) what Monadical is and does, (2) its main products, services, or labs, (3) any projects or products named 'Greyhaven' or 'Greyhaven Labs', (4) any description of how Greyhaven relates to Monadical. Return your findings in 5-8 concise bullet points.\"}]}], \"max_output_tokens\": 32000, \"text\": {\"verbosity\": \"low\"}, \"store\": false, \"include\": [\"reasoning.encrypted_content\"], \"prompt_cache_key\": \"ses_2fd479e75ffepAe8R23CkidGZC\", \"reasoning\": {\"effort\": \"medium\", \"summary\": \"auto\"}, \"tools\": [{\"type\": \"function\", \"name\": \"bash\", \"description\": \"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\\n\\nAll commands run in /home/tito/code/monadical/greyproxy by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.\\n\\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\\n\\nBefore executing the command, please follow these steps:\\n\\n1. Directory Verification:\\n - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location\\n - For example, before running \\\"mkdir foo/bar\\\", first use `ls foo` to check that \\\"foo\\\" exists and is the intended parent directory\\n\\n2. Command Execution:\\n - Always quote file paths that contain spaces with double quotes (e.g., rm \\\"path with spaces/file.txt\\\")\\n - Examples of proper quoting:\\n - mkdir \\\"/Users/name/My Documents\\\" (correct)\\n - mkdir /Users/name/My Documents (incorrect - will fail)\\n - python \\\"/path/with spaces/script.py\\\" (correct)\\n - python /path/with spaces/script.py (incorrect - will fail)\\n - After ensuring proper quoting, execute the command.\\n - Capture the output of the command.\\n\\nUsage notes:\\n - The command argument is required.\\n - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).\\n - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.\\n - If the output exceeds 2000 lines or 51200 bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.\\n\\n - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:\\n - File search: Use Glob (NOT find or ls)\\n - Content search: Use Grep (NOT grep or rg)\\n - Read files: Use Read (NOT cat/head/tail)\\n - Edit files: Use Edit (NOT sed/awk)\\n - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead.\\n \\n Use workdir=\\\"/foo/bar\\\" with command: pytest tests\\n \\n \\n cd /foo/bar && pytest tests\\n \\n\\n# Committing changes with git\\n\\nOnly create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:\\n\\nGit Safety Protocol:\\n- NEVER update the git config\\n- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them\\n- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it\\n- NEVER run force push to main/master, warn the user if they request it\\n- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:\\n (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including\\n (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')\\n (3) Commit has NOT been pushed to remote (verify: git status shows \\\"Your branch is ahead\\\")\\n- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit\\n- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)\\n- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:\\n - Run a git status command to see all untracked files.\\n - Run a git diff command to see both staged and unstaged changes that will be committed.\\n - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\\n2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:\\n - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. \\\"add\\\" means a wholly new feature, \\\"update\\\" means an enhancement to an existing feature, \\\"fix\\\" means a bug fix, etc.).\\n - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files\\n - Draft a concise (1-2 sentences) commit message that focuses on the \\\"why\\\" rather than the \\\"what\\\"\\n - Ensure it accurately reflects the changes and their purpose\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:\\n - Add relevant untracked files to the staging area.\\n - Create the commit with a message\\n - Run git status after the commit completes to verify success.\\n Note: git status depends on the commit completing, so run it sequentially after the commit.\\n4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)\\n\\nImportant notes:\\n- NEVER run additional commands to read or explore code, besides git bash commands\\n- NEVER use the TodoWrite or Task tools\\n- DO NOT push to the remote repository unless the user explicitly asks you to do so\\n- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\\n- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\\n\\n# Creating pull requests\\nUse the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.\\n\\nIMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:\\n - Run a git status command to see all untracked files\\n - Run a git diff command to see both staged and unstaged changes that will be committed\\n - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\\n - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)\\n2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:\\n - Create new branch if needed\\n - Push to remote with -u flag if needed\\n - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\\n\\ngh pr create --title \\\"the pr title\\\" --body \\\"$(cat <<'EOF'\\n## Summary\\n<1-3 bullet points>\\n\\n\\nImportant:\\n- DO NOT use the TodoWrite or Task tools\\n- Return the PR URL when you're done, so the user can see it\\n\\n# Other common operations\\n- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments\\n\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"command\": {\"description\": \"The command to execute\", \"type\": \"string\"}, \"timeout\": {\"description\": \"Optional timeout in milliseconds\", \"type\": \"number\"}, \"workdir\": {\"description\": \"The working directory to run the command in. Defaults to /home/tito/code/monadical/greyproxy. Use this instead of 'cd' commands.\", \"type\": \"string\"}, \"description\": {\"description\": \"Clear, concise description of what this command does in 5-10 words. Examples:\\nInput: ls\\nOutput: Lists files in current directory\\n\\nInput: git status\\nOutput: Shows working tree status\\n\\nInput: npm install\\nOutput: Installs package dependencies\\n\\nInput: mkdir foo\\nOutput: Creates directory 'foo'\", \"type\": \"string\"}}, \"required\": [\"command\", \"description\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"name\": \"read\", \"description\": \"Read a file or directory from the local filesystem. If the path does not exist, an error is returned.\\n\\nUsage:\\n- The filePath parameter should be an absolute path.\\n- By default, this tool returns up to 2000 lines from the start of the file.\\n- The offset parameter is the line number to start from (1-indexed).\\n- To read later sections, call this tool again with a larger offset.\\n- Use the grep tool to find specific content in large files or files with long lines.\\n- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.\\n- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents \\\"foo\\\\n\\\", you will receive \\\"1: foo\\\\n\\\". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.\\n- Any line longer than 2000 characters is truncated.\\n- Call this tool in parallel when you know there are multiple files you want to read.\\n- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.\\n- This tool can read image files and PDFs and return them as file attachments.\\n\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"filePath\": {\"description\": \"The absolute path to the file or directory to read\", \"type\": \"string\"}, \"offset\": {\"description\": \"The line number to start reading from (1-indexed)\", \"type\": \"number\"}, \"limit\": {\"description\": \"The maximum number of lines to read (defaults to 2000)\", \"type\": \"number\"}}, \"required\": [\"filePath\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"name\": \"glob\", \"description\": \"- Fast file pattern matching tool that works with any codebase size\\n- Supports glob patterns like \\\"**/*.js\\\" or \\\"src/**/*.ts\\\"\\n- Returns matching file paths sorted by modification time\\n- Use this tool when you need to find files by name patterns\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.\\n\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"pattern\": {\"description\": \"The glob pattern to match files against\", \"type\": \"string\"}, \"path\": {\"description\": \"The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \\\"undefined\\\" or \\\"null\\\" - simply omit it for the default behavior. Must be a valid directory path if provided.\", \"type\": \"string\"}}, \"required\": [\"pattern\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"name\": \"grep\", \"description\": \"- Fast content search tool that works with any codebase size\\n- Searches file contents using regular expressions\\n- Supports full regex syntax (eg. \\\"log.*Error\\\", \\\"function\\\\s+\\\\w+\\\", etc.)\\n- Filter files by pattern with the include parameter (eg. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\\n- Returns file paths and line numbers with at least one match sorted by modification time\\n- Use this tool when you need to find files containing specific patterns\\n- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"pattern\": {\"description\": \"The regex pattern to search for in file contents\", \"type\": \"string\"}, \"path\": {\"description\": \"The directory to search in. Defaults to the current working directory.\", \"type\": \"string\"}, \"include\": {\"description\": \"File pattern to include in the search (e.g. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\", \"type\": \"string\"}}, \"required\": [\"pattern\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"name\": \"webfetch\", \"description\": \"- Fetches content from a specified URL\\n- Takes a URL and optional format as input\\n- Fetches the URL content, converts to requested format (markdown by default)\\n- Returns the content in the specified format\\n- Use this tool when you need to retrieve and analyze web content\\n\\nUsage notes:\\n - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one.\\n - The URL must be a fully-formed valid URL\\n - HTTP URLs will be automatically upgraded to HTTPS\\n - Format options: \\\"markdown\\\" (default), \\\"text\\\", or \\\"html\\\"\\n - This tool is read-only and does not modify any files\\n - Results may be summarized if the content is very large\\n\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"url\": {\"description\": \"The URL to fetch content from\", \"type\": \"string\"}, \"format\": {\"description\": \"The format to return the content in (text, markdown, or html). Defaults to markdown.\", \"default\": \"markdown\", \"type\": \"string\", \"enum\": [\"text\", \"markdown\", \"html\"]}, \"timeout\": {\"description\": \"Optional timeout in seconds (max 120)\", \"type\": \"number\"}}, \"required\": [\"url\", \"format\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"name\": \"skill\", \"description\": \"Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available.\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"name\": {\"description\": \"The name of the skill from available_skills\", \"type\": \"string\"}}, \"required\": [\"name\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"name\": \"apply_patch\", \"description\": \"Use the `apply_patch` tool to edit files. Your patch language is a stripped\\u2011down, file\\u2011oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high\\u2011level envelope:\\n\\n*** Begin Patch\\n[ one or more file sections ]\\n*** End Patch\\n\\nWithin that envelope, you get a sequence of file operations.\\nYou MUST include a header to specify the action you are taking.\\nEach operation starts with one of three headers:\\n\\n*** Add File: - create a new file. Every following line is a + line (the initial contents).\\n*** Delete File: - remove an existing file. Nothing follows.\\n*** Update File: - patch an existing file in place (optionally with a rename).\\n\\nExample patch:\\n\\n```\\n*** Begin Patch\\n*** Add File: hello.txt\\n+Hello world\\n*** Update File: src/app.py\\n*** Move to: src/main.py\\n@@ def greet():\\n-print(\\\"Hi\\\")\\n+print(\\\"Hello, world!\\\")\\n*** Delete File: obsolete.txt\\n*** End Patch\\n```\\n\\nIt is important to remember:\\n\\n- You must include a header with your intended action (Add/Delete/Update)\\n- You must prefix new lines with `+` even when creating a new file\\n\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"patchText\": {\"description\": \"The full patch text that describes all changes to be made\", \"type\": \"string\"}}, \"required\": [\"patchText\"], \"additionalProperties\": false}, \"strict\": false}], \"tool_choice\": \"auto\", \"stream\": true}", + "response_body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0f7062f8b56f05880169bb107e41788195b0b0133e830420bf\",\"object\":\"response\",\"created_at\":1773867134,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":32000,\"max_tool_calls\":null,\"model\":\"gpt-5.1-2025-11-13\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"ses_2fd479e75ffepAe8R23CkidGZC\",\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\\n\\nAll commands run in /home/tito/code/monadical/greyproxy by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.\\n\\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\\n\\nBefore executing the command, please follow these steps:\\n\\n1. Directory Verification:\\n - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location\\n - For example, before running \\\"mkdir foo/bar\\\", first use `ls foo` to check that \\\"foo\\\" exists and is the intended parent directory\\n\\n2. Command Execution:\\n - Always quote file paths that contain spaces with double quotes (e.g., rm \\\"path with spaces/file.txt\\\")\\n - Examples of proper quoting:\\n - mkdir \\\"/Users/name/My Documents\\\" (correct)\\n - mkdir /Users/name/My Documents (incorrect - will fail)\\n - python \\\"/path/with spaces/script.py\\\" (correct)\\n - python /path/with spaces/script.py (incorrect - will fail)\\n - After ensuring proper quoting, execute the command.\\n - Capture the output of the command.\\n\\nUsage notes:\\n - The command argument is required.\\n - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).\\n - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.\\n - If the output exceeds 2000 lines or 51200 bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.\\n\\n - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:\\n - File search: Use Glob (NOT find or ls)\\n - Content search: Use Grep (NOT grep or rg)\\n - Read files: Use Read (NOT cat/head/tail)\\n - Edit files: Use Edit (NOT sed/awk)\\n - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead.\\n \\n Use workdir=\\\"/foo/bar\\\" with command: pytest tests\\n \\n \\n cd /foo/bar && pytest tests\\n \\n\\n# Committing changes with git\\n\\nOnly create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:\\n\\nGit Safety Protocol:\\n- NEVER update the git config\\n- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them\\n- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it\\n- NEVER run force push to main/master, warn the user if they request it\\n- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:\\n (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including\\n (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')\\n (3) Commit has NOT been pushed to remote (verify: git status shows \\\"Your branch is ahead\\\")\\n- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit\\n- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)\\n- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:\\n - Run a git status command to see all untracked files.\\n - Run a git diff command to see both staged and unstaged changes that will be committed.\\n - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\\n2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:\\n - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. \\\"add\\\" means a wholly new feature, \\\"update\\\" means an enhancement to an existing feature, \\\"fix\\\" means a bug fix, etc.).\\n - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files\\n - Draft a concise (1-2 sentences) commit message that focuses on the \\\"why\\\" rather than the \\\"what\\\"\\n - Ensure it accurately reflects the changes and their purpose\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:\\n - Add relevant untracked files to the staging area.\\n - Create the commit with a message\\n - Run git status after the commit completes to verify success.\\n Note: git status depends on the commit completing, so run it sequentially after the commit.\\n4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)\\n\\nImportant notes:\\n- NEVER run additional commands to read or explore code, besides git bash commands\\n- NEVER use the TodoWrite or Task tools\\n- DO NOT push to the remote repository unless the user explicitly asks you to do so\\n- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\\n- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\\n\\n# Creating pull requests\\nUse the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.\\n\\nIMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:\\n - Run a git status command to see all untracked files\\n - Run a git diff command to see both staged and unstaged changes that will be committed\\n - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\\n - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)\\n2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:\\n - Create new branch if needed\\n - Push to remote with -u flag if needed\\n - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\\n\\ngh pr create --title \\\"the pr title\\\" --body \\\"$(cat <<'EOF'\\n## Summary\\n<1-3 bullet points>\\n\\n\\nImportant:\\n- DO NOT use the TodoWrite or Task tools\\n- Return the PR URL when you're done, so the user can see it\\n\\n# Other common operations\\n- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments\\n\",\"name\":\"bash\",\"parameters\":{\"type\":\"object\",\"properties\":{\"command\":{\"description\":\"The command to execute\",\"type\":\"string\"},\"timeout\":{\"description\":\"Optional timeout in milliseconds\",\"type\":\"number\"},\"workdir\":{\"description\":\"The working directory to run the command in. Defaults to /home/tito/code/monadical/greyproxy. Use this instead of 'cd' commands.\",\"type\":\"string\"},\"description\":{\"description\":\"Clear, concise description of what this command does in 5-10 words. Examples:\\nInput: ls\\nOutput: Lists files in current directory\\n\\nInput: git status\\nOutput: Shows working tree status\\n\\nInput: npm install\\nOutput: Installs package dependencies\\n\\nInput: mkdir foo\\nOutput: Creates directory 'foo'\",\"type\":\"string\"}},\"required\":[\"command\",\"description\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"Read a file or directory from the local filesystem. If the path does not exist, an error is returned.\\n\\nUsage:\\n- The filePath parameter should be an absolute path.\\n- By default, this tool returns up to 2000 lines from the start of the file.\\n- The offset parameter is the line number to start from (1-indexed).\\n- To read later sections, call this tool again with a larger offset.\\n- Use the grep tool to find specific content in large files or files with long lines.\\n- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.\\n- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents \\\"foo\\\\n\\\", you will receive \\\"1: foo\\\\n\\\". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.\\n- Any line longer than 2000 characters is truncated.\\n- Call this tool in parallel when you know there are multiple files you want to read.\\n- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.\\n- This tool can read image files and PDFs and return them as file attachments.\\n\",\"name\":\"read\",\"parameters\":{\"type\":\"object\",\"properties\":{\"filePath\":{\"description\":\"The absolute path to the file or directory to read\",\"type\":\"string\"},\"offset\":{\"description\":\"The line number to start reading from (1-indexed)\",\"type\":\"number\"},\"limit\":{\"description\":\"The maximum number of lines to read (defaults to 2000)\",\"type\":\"number\"}},\"required\":[\"filePath\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"- Fast file pattern matching tool that works with any codebase size\\n- Supports glob patterns like \\\"**/*.js\\\" or \\\"src/**/*.ts\\\"\\n- Returns matching file paths sorted by modification time\\n- Use this tool when you need to find files by name patterns\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.\\n\",\"name\":\"glob\",\"parameters\":{\"type\":\"object\",\"properties\":{\"pattern\":{\"description\":\"The glob pattern to match files against\",\"type\":\"string\"},\"path\":{\"description\":\"The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \\\"undefined\\\" or \\\"null\\\" - simply omit it for the default behavior. Must be a valid directory path if provided.\",\"type\":\"string\"}},\"required\":[\"pattern\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"- Fast content search tool that works with any codebase size\\n- Searches file contents using regular expressions\\n- Supports full regex syntax (eg. \\\"log.*Error\\\", \\\"function\\\\s+\\\\w+\\\", etc.)\\n- Filter files by pattern with the include parameter (eg. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\\n- Returns file paths and line numbers with at least one match sorted by modification time\\n- Use this tool when you need to find files containing specific patterns\\n- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n\",\"name\":\"grep\",\"parameters\":{\"type\":\"object\",\"properties\":{\"pattern\":{\"description\":\"The regex pattern to search for in file contents\",\"type\":\"string\"},\"path\":{\"description\":\"The directory to search in. Defaults to the current working directory.\",\"type\":\"string\"},\"include\":{\"description\":\"File pattern to include in the search (e.g. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\",\"type\":\"string\"}},\"required\":[\"pattern\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"- Fetches content from a specified URL\\n- Takes a URL and optional format as input\\n- Fetches the URL content, converts to requested format (markdown by default)\\n- Returns the content in the specified format\\n- Use this tool when you need to retrieve and analyze web content\\n\\nUsage notes:\\n - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one.\\n - The URL must be a fully-formed valid URL\\n - HTTP URLs will be automatically upgraded to HTTPS\\n - Format options: \\\"markdown\\\" (default), \\\"text\\\", or \\\"html\\\"\\n - This tool is read-only and does not modify any files\\n - Results may be summarized if the content is very large\\n\",\"name\":\"webfetch\",\"parameters\":{\"type\":\"object\",\"properties\":{\"url\":{\"description\":\"The URL to fetch content from\",\"type\":\"string\"},\"format\":{\"description\":\"The format to return the content in (text, markdown, or html). Defaults to markdown.\",\"default\":\"markdown\",\"type\":\"string\",\"enum\":[\"text\",\"markdown\",\"html\"]},\"timeout\":{\"description\":\"Optional timeout in seconds (max 120)\",\"type\":\"number\"}},\"required\":[\"url\",\"format\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available.\",\"name\":\"skill\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"description\":\"The name of the skill from available_skills\",\"type\":\"string\"}},\"required\":[\"name\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"Use the `apply_patch` tool to edit files. Your patch language is a stripped\u2011down, file\u2011oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high\u2011level envelope:\\n\\n*** Begin Patch\\n[ one or more file sections ]\\n*** End Patch\\n\\nWithin that envelope, you get a sequence of file operations.\\nYou MUST include a header to specify the action you are taking.\\nEach operation starts with one of three headers:\\n\\n*** Add File: - create a new file. Every following line is a + line (the initial contents).\\n*** Delete File: - remove an existing file. Nothing follows.\\n*** Update File: - patch an existing file in place (optionally with a rename).\\n\\nExample patch:\\n\\n```\\n*** Begin Patch\\n*** Add File: hello.txt\\n+Hello world\\n*** Update File: src/app.py\\n*** Move to: src/main.py\\n@@ def greet():\\n-print(\\\"Hi\\\")\\n+print(\\\"Hello, world!\\\")\\n*** Delete File: obsolete.txt\\n*** End Patch\\n```\\n\\nIt is important to remember:\\n\\n- You must include a header with your intended action (Add/Delete/Update)\\n- You must prefix new lines with `+` even when creating a new file\\n\",\"name\":\"apply_patch\",\"parameters\":{\"type\":\"object\",\"properties\":{\"patchText\":{\"description\":\"The full patch text that describes all changes to be made\",\"type\":\"string\"}},\"required\":[\"patchText\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0f7062f8b56f05880169bb107e41788195b0b0133e830420bf\",\"object\":\"response\",\"created_at\":1773867134,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":32000,\"max_tool_calls\":null,\"model\":\"gpt-5.1-2025-11-13\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"ses_2fd479e75ffepAe8R23CkidGZC\",\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\\n\\nAll commands run in /home/tito/code/monadical/greyproxy by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.\\n\\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\\n\\nBefore executing the command, please follow these steps:\\n\\n1. Directory Verification:\\n - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location\\n - For example, before running \\\"mkdir foo/bar\\\", first use `ls foo` to check that \\\"foo\\\" exists and is the intended parent directory\\n\\n2. Command Execution:\\n - Always quote file paths that contain spaces with double quotes (e.g., rm \\\"path with spaces/file.txt\\\")\\n - Examples of proper quoting:\\n - mkdir \\\"/Users/name/My Documents\\\" (correct)\\n - mkdir /Users/name/My Documents (incorrect - will fail)\\n - python \\\"/path/with spaces/script.py\\\" (correct)\\n - python /path/with spaces/script.py (incorrect - will fail)\\n - After ensuring proper quoting, execute the command.\\n - Capture the output of the command.\\n\\nUsage notes:\\n - The command argument is required.\\n - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).\\n - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.\\n - If the output exceeds 2000 lines or 51200 bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.\\n\\n - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:\\n - File search: Use Glob (NOT find or ls)\\n - Content search: Use Grep (NOT grep or rg)\\n - Read files: Use Read (NOT cat/head/tail)\\n - Edit files: Use Edit (NOT sed/awk)\\n - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead.\\n \\n Use workdir=\\\"/foo/bar\\\" with command: pytest tests\\n \\n \\n cd /foo/bar && pytest tests\\n \\n\\n# Committing changes with git\\n\\nOnly create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:\\n\\nGit Safety Protocol:\\n- NEVER update the git config\\n- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them\\n- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it\\n- NEVER run force push to main/master, warn the user if they request it\\n- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:\\n (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including\\n (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')\\n (3) Commit has NOT been pushed to remote (verify: git status shows \\\"Your branch is ahead\\\")\\n- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit\\n- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)\\n- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:\\n - Run a git status command to see all untracked files.\\n - Run a git diff command to see both staged and unstaged changes that will be committed.\\n - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\\n2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:\\n - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. \\\"add\\\" means a wholly new feature, \\\"update\\\" means an enhancement to an existing feature, \\\"fix\\\" means a bug fix, etc.).\\n - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files\\n - Draft a concise (1-2 sentences) commit message that focuses on the \\\"why\\\" rather than the \\\"what\\\"\\n - Ensure it accurately reflects the changes and their purpose\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:\\n - Add relevant untracked files to the staging area.\\n - Create the commit with a message\\n - Run git status after the commit completes to verify success.\\n Note: git status depends on the commit completing, so run it sequentially after the commit.\\n4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)\\n\\nImportant notes:\\n- NEVER run additional commands to read or explore code, besides git bash commands\\n- NEVER use the TodoWrite or Task tools\\n- DO NOT push to the remote repository unless the user explicitly asks you to do so\\n- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\\n- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\\n\\n# Creating pull requests\\nUse the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.\\n\\nIMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:\\n - Run a git status command to see all untracked files\\n - Run a git diff command to see both staged and unstaged changes that will be committed\\n - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\\n - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)\\n2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:\\n - Create new branch if needed\\n - Push to remote with -u flag if needed\\n - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\\n\\ngh pr create --title \\\"the pr title\\\" --body \\\"$(cat <<'EOF'\\n## Summary\\n<1-3 bullet points>\\n\\n\\nImportant:\\n- DO NOT use the TodoWrite or Task tools\\n- Return the PR URL when you're done, so the user can see it\\n\\n# Other common operations\\n- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments\\n\",\"name\":\"bash\",\"parameters\":{\"type\":\"object\",\"properties\":{\"command\":{\"description\":\"The command to execute\",\"type\":\"string\"},\"timeout\":{\"description\":\"Optional timeout in milliseconds\",\"type\":\"number\"},\"workdir\":{\"description\":\"The working directory to run the command in. Defaults to /home/tito/code/monadical/greyproxy. Use this instead of 'cd' commands.\",\"type\":\"string\"},\"description\":{\"description\":\"Clear, concise description of what this command does in 5-10 words. Examples:\\nInput: ls\\nOutput: Lists files in current directory\\n\\nInput: git status\\nOutput: Shows working tree status\\n\\nInput: npm install\\nOutput: Installs package dependencies\\n\\nInput: mkdir foo\\nOutput: Creates directory 'foo'\",\"type\":\"string\"}},\"required\":[\"command\",\"description\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"Read a file or directory from the local filesystem. If the path does not exist, an error is returned.\\n\\nUsage:\\n- The filePath parameter should be an absolute path.\\n- By default, this tool returns up to 2000 lines from the start of the file.\\n- The offset parameter is the line number to start from (1-indexed).\\n- To read later sections, call this tool again with a larger offset.\\n- Use the grep tool to find specific content in large files or files with long lines.\\n- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.\\n- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents \\\"foo\\\\n\\\", you will receive \\\"1: foo\\\\n\\\". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.\\n- Any line longer than 2000 characters is truncated.\\n- Call this tool in parallel when you know there are multiple files you want to read.\\n- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.\\n- This tool can read image files and PDFs and return them as file attachments.\\n\",\"name\":\"read\",\"parameters\":{\"type\":\"object\",\"properties\":{\"filePath\":{\"description\":\"The absolute path to the file or directory to read\",\"type\":\"string\"},\"offset\":{\"description\":\"The line number to start reading from (1-indexed)\",\"type\":\"number\"},\"limit\":{\"description\":\"The maximum number of lines to read (defaults to 2000)\",\"type\":\"number\"}},\"required\":[\"filePath\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"- Fast file pattern matching tool that works with any codebase size\\n- Supports glob patterns like \\\"**/*.js\\\" or \\\"src/**/*.ts\\\"\\n- Returns matching file paths sorted by modification time\\n- Use this tool when you need to find files by name patterns\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.\\n\",\"name\":\"glob\",\"parameters\":{\"type\":\"object\",\"properties\":{\"pattern\":{\"description\":\"The glob pattern to match files against\",\"type\":\"string\"},\"path\":{\"description\":\"The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \\\"undefined\\\" or \\\"null\\\" - simply omit it for the default behavior. Must be a valid directory path if provided.\",\"type\":\"string\"}},\"required\":[\"pattern\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"- Fast content search tool that works with any codebase size\\n- Searches file contents using regular expressions\\n- Supports full regex syntax (eg. \\\"log.*Error\\\", \\\"function\\\\s+\\\\w+\\\", etc.)\\n- Filter files by pattern with the include parameter (eg. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\\n- Returns file paths and line numbers with at least one match sorted by modification time\\n- Use this tool when you need to find files containing specific patterns\\n- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n\",\"name\":\"grep\",\"parameters\":{\"type\":\"object\",\"properties\":{\"pattern\":{\"description\":\"The regex pattern to search for in file contents\",\"type\":\"string\"},\"path\":{\"description\":\"The directory to search in. Defaults to the current working directory.\",\"type\":\"string\"},\"include\":{\"description\":\"File pattern to include in the search (e.g. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\",\"type\":\"string\"}},\"required\":[\"pattern\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"- Fetches content from a specified URL\\n- Takes a URL and optional format as input\\n- Fetches the URL content, converts to requested format (markdown by default)\\n- Returns the content in the specified format\\n- Use this tool when you need to retrieve and analyze web content\\n\\nUsage notes:\\n - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one.\\n - The URL must be a fully-formed valid URL\\n - HTTP URLs will be automatically upgraded to HTTPS\\n - Format options: \\\"markdown\\\" (default), \\\"text\\\", or \\\"html\\\"\\n - This tool is read-only and does not modify any files\\n - Results may be summarized if the content is very large\\n\",\"name\":\"webfetch\",\"parameters\":{\"type\":\"object\",\"properties\":{\"url\":{\"description\":\"The URL to fetch content from\",\"type\":\"string\"},\"format\":{\"description\":\"The format to return the content in (text, markdown, or html). Defaults to markdown.\",\"default\":\"markdown\",\"type\":\"string\",\"enum\":[\"text\",\"markdown\",\"html\"]},\"timeout\":{\"description\":\"Optional timeout in seconds (max 120)\",\"type\":\"number\"}},\"required\":[\"url\",\"format\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available.\",\"name\":\"skill\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"description\":\"The name of the skill from available_skills\",\"type\":\"string\"}},\"required\":[\"name\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"Use the `apply_patch` tool to edit files. Your patch language is a stripped\u2011down, file\u2011oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high\u2011level envelope:\\n\\n*** Begin Patch\\n[ one or more file sections ]\\n*** End Patch\\n\\nWithin that envelope, you get a sequence of file operations.\\nYou MUST include a header to specify the action you are taking.\\nEach operation starts with one of three headers:\\n\\n*** Add File: - create a new file. Every following line is a + line (the initial contents).\\n*** Delete File: - remove an existing file. Nothing follows.\\n*** Update File: - patch an existing file in place (optionally with a rename).\\n\\nExample patch:\\n\\n```\\n*** Begin Patch\\n*** Add File: hello.txt\\n+Hello world\\n*** Update File: src/app.py\\n*** Move to: src/main.py\\n@@ def greet():\\n-print(\\\"Hi\\\")\\n+print(\\\"Hello, world!\\\")\\n*** Delete File: obsolete.txt\\n*** End Patch\\n```\\n\\nIt is important to remember:\\n\\n- You must include a header with your intended action (Add/Delete/Update)\\n- You must prefix new lines with `+` even when creating a new file\\n\",\"name\":\"apply_patch\",\"parameters\":{\"type\":\"object\",\"properties\":{\"patchText\":{\"description\":\"The full patch text that describes all changes to be made\",\"type\":\"string\"}},\"required\":[\"patchText\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\": \"response.output_item.added\", \"item\": {\"id\": \"rs_0f7062f8b56f05880169bb107f131481959ed9308b3339f6a4\", \"type\": \"reasoning\", \"encrypted_content\": \"REDACTED_FOR_TEST\", \"summary\": []}, \"output_index\": 0, \"sequence_number\": 2}\n\nevent: response.output_item.done\ndata: {\"type\": \"response.output_item.done\", \"item\": {\"id\": \"rs_0f7062f8b56f05880169bb107f131481959ed9308b3339f6a4\", \"type\": \"reasoning\", \"encrypted_content\": \"REDACTED_FOR_TEST\", \"summary\": []}, \"output_index\": 0, \"sequence_number\": 3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_0f7062f8b56f05880169bb108008b481958e6307ef9d5f51d7\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_evip66KMkwxzRw2BmdanlpX3\",\"name\":\"webfetch\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"url\\\":\\\"https://monadical.com\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\",\"item_id\":\"fc_0f7062f8b56f05880169bb108008b481958e6307ef9d5f51d7\",\"obfuscation\":\"\",\"output_index\":1,\"sequence_number\":5}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"url\\\":\\\"https://monadical.com\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\",\"item_id\":\"fc_0f7062f8b56f05880169bb108008b481958e6307ef9d5f51d7\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_0f7062f8b56f05880169bb108008b481958e6307ef9d5f51d7\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"url\\\":\\\"https://monadical.com\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\",\"call_id\":\"call_evip66KMkwxzRw2BmdanlpX3\",\"name\":\"webfetch\"},\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_0f7062f8b56f05880169bb108008c8819585dc530881f65ef0\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_JMw5hLEycCbjvFA9j0GCzXoW\",\"name\":\"webfetch\"},\"output_index\":2,\"sequence_number\":8}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"url\\\":\\\"https://greyhaven.ai\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\",\"item_id\":\"fc_0f7062f8b56f05880169bb108008c8819585dc530881f65ef0\",\"obfuscation\":\"e\",\"output_index\":2,\"sequence_number\":9}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"url\\\":\\\"https://greyhaven.ai\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\",\"item_id\":\"fc_0f7062f8b56f05880169bb108008c8819585dc530881f65ef0\",\"output_index\":2,\"sequence_number\":10}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_0f7062f8b56f05880169bb108008c8819585dc530881f65ef0\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"url\\\":\\\"https://greyhaven.ai\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\",\"call_id\":\"call_JMw5hLEycCbjvFA9j0GCzXoW\",\"name\":\"webfetch\"},\"output_index\":2,\"sequence_number\":11}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_0f7062f8b56f05880169bb108008d0819581cc4b466dc72f9f\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_MvG9uewldptnlkgqzST9kkkY\",\"name\":\"webfetch\"},\"output_index\":3,\"sequence_number\":12}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"url\\\":\\\"https://greyhaven.monadical.com\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\",\"item_id\":\"fc_0f7062f8b56f05880169bb108008d0819581cc4b466dc72f9f\",\"obfuscation\":\"nVqkrK\",\"output_index\":3,\"sequence_number\":13}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"url\\\":\\\"https://greyhaven.monadical.com\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\",\"item_id\":\"fc_0f7062f8b56f05880169bb108008d0819581cc4b466dc72f9f\",\"output_index\":3,\"sequence_number\":14}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_0f7062f8b56f05880169bb108008d0819581cc4b466dc72f9f\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"url\\\":\\\"https://greyhaven.monadical.com\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\",\"call_id\":\"call_MvG9uewldptnlkgqzST9kkkY\",\"name\":\"webfetch\"},\"output_index\":3,\"sequence_number\":15}\n\nevent: response.completed\ndata: {\"type\": \"response.completed\", \"response\": {\"id\": \"resp_0f7062f8b56f05880169bb107e41788195b0b0133e830420bf\", \"object\": \"response\", \"created_at\": 1773867134, \"status\": \"completed\", \"background\": false, \"completed_at\": 1773867136, \"error\": null, \"frequency_penalty\": 0.0, \"incomplete_details\": null, \"instructions\": null, \"max_output_tokens\": 32000, \"max_tool_calls\": null, \"model\": \"gpt-5.1-2025-11-13\", \"output\": [{\"id\": \"rs_0f7062f8b56f05880169bb107f131481959ed9308b3339f6a4\", \"type\": \"reasoning\", \"encrypted_content\": \"REDACTED_FOR_TEST\", \"summary\": []}, {\"id\": \"fc_0f7062f8b56f05880169bb108008b481958e6307ef9d5f51d7\", \"type\": \"function_call\", \"status\": \"completed\", \"arguments\": \"{\\\"url\\\":\\\"https://monadical.com\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\", \"call_id\": \"call_evip66KMkwxzRw2BmdanlpX3\", \"name\": \"webfetch\"}, {\"id\": \"fc_0f7062f8b56f05880169bb108008c8819585dc530881f65ef0\", \"type\": \"function_call\", \"status\": \"completed\", \"arguments\": \"{\\\"url\\\":\\\"https://greyhaven.ai\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\", \"call_id\": \"call_JMw5hLEycCbjvFA9j0GCzXoW\", \"name\": \"webfetch\"}, {\"id\": \"fc_0f7062f8b56f05880169bb108008d0819581cc4b466dc72f9f\", \"type\": \"function_call\", \"status\": \"completed\", \"arguments\": \"{\\\"url\\\":\\\"https://greyhaven.monadical.com\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\", \"call_id\": \"call_MvG9uewldptnlkgqzST9kkkY\", \"name\": \"webfetch\"}], \"parallel_tool_calls\": true, \"presence_penalty\": 0.0, \"previous_response_id\": null, \"prompt_cache_key\": \"ses_2fd479e75ffepAe8R23CkidGZC\", \"prompt_cache_retention\": null, \"reasoning\": {\"effort\": \"medium\", \"summary\": \"detailed\"}, \"safety_identifier\": null, \"service_tier\": \"default\", \"store\": false, \"temperature\": 1.0, \"text\": {\"format\": {\"type\": \"text\"}, \"verbosity\": \"low\"}, \"tool_choice\": \"auto\", \"tools\": [{\"type\": \"function\", \"description\": \"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\\n\\nAll commands run in /home/tito/code/monadical/greyproxy by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.\\n\\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\\n\\nBefore executing the command, please follow these steps:\\n\\n1. Directory Verification:\\n - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location\\n - For example, before running \\\"mkdir foo/bar\\\", first use `ls foo` to check that \\\"foo\\\" exists and is the intended parent directory\\n\\n2. Command Execution:\\n - Always quote file paths that contain spaces with double quotes (e.g., rm \\\"path with spaces/file.txt\\\")\\n - Examples of proper quoting:\\n - mkdir \\\"/Users/name/My Documents\\\" (correct)\\n - mkdir /Users/name/My Documents (incorrect - will fail)\\n - python \\\"/path/with spaces/script.py\\\" (correct)\\n - python /path/with spaces/script.py (incorrect - will fail)\\n - After ensuring proper quoting, execute the command.\\n - Capture the output of the command.\\n\\nUsage notes:\\n - The command argument is required.\\n - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).\\n - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.\\n - If the output exceeds 2000 lines or 51200 bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.\\n\\n - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:\\n - File search: Use Glob (NOT find or ls)\\n - Content search: Use Grep (NOT grep or rg)\\n - Read files: Use Read (NOT cat/head/tail)\\n - Edit files: Use Edit (NOT sed/awk)\\n - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead.\\n \\n Use workdir=\\\"/foo/bar\\\" with command: pytest tests\\n \\n \\n cd /foo/bar && pytest tests\\n \\n\\n# Committing changes with git\\n\\nOnly create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:\\n\\nGit Safety Protocol:\\n- NEVER update the git config\\n- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them\\n- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it\\n- NEVER run force push to main/master, warn the user if they request it\\n- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:\\n (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including\\n (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')\\n (3) Commit has NOT been pushed to remote (verify: git status shows \\\"Your branch is ahead\\\")\\n- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit\\n- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)\\n- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:\\n - Run a git status command to see all untracked files.\\n - Run a git diff command to see both staged and unstaged changes that will be committed.\\n - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\\n2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:\\n - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. \\\"add\\\" means a wholly new feature, \\\"update\\\" means an enhancement to an existing feature, \\\"fix\\\" means a bug fix, etc.).\\n - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files\\n - Draft a concise (1-2 sentences) commit message that focuses on the \\\"why\\\" rather than the \\\"what\\\"\\n - Ensure it accurately reflects the changes and their purpose\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:\\n - Add relevant untracked files to the staging area.\\n - Create the commit with a message\\n - Run git status after the commit completes to verify success.\\n Note: git status depends on the commit completing, so run it sequentially after the commit.\\n4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)\\n\\nImportant notes:\\n- NEVER run additional commands to read or explore code, besides git bash commands\\n- NEVER use the TodoWrite or Task tools\\n- DO NOT push to the remote repository unless the user explicitly asks you to do so\\n- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\\n- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\\n\\n# Creating pull requests\\nUse the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.\\n\\nIMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:\\n - Run a git status command to see all untracked files\\n - Run a git diff command to see both staged and unstaged changes that will be committed\\n - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\\n - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)\\n2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:\\n - Create new branch if needed\\n - Push to remote with -u flag if needed\\n - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\\n\\ngh pr create --title \\\"the pr title\\\" --body \\\"$(cat <<'EOF'\\n## Summary\\n<1-3 bullet points>\\n\\n\\nImportant:\\n- DO NOT use the TodoWrite or Task tools\\n- Return the PR URL when you're done, so the user can see it\\n\\n# Other common operations\\n- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments\\n\", \"name\": \"bash\", \"parameters\": {\"type\": \"object\", \"properties\": {\"command\": {\"description\": \"The command to execute\", \"type\": \"string\"}, \"timeout\": {\"description\": \"Optional timeout in milliseconds\", \"type\": \"number\"}, \"workdir\": {\"description\": \"The working directory to run the command in. Defaults to /home/tito/code/monadical/greyproxy. Use this instead of 'cd' commands.\", \"type\": \"string\"}, \"description\": {\"description\": \"Clear, concise description of what this command does in 5-10 words. Examples:\\nInput: ls\\nOutput: Lists files in current directory\\n\\nInput: git status\\nOutput: Shows working tree status\\n\\nInput: npm install\\nOutput: Installs package dependencies\\n\\nInput: mkdir foo\\nOutput: Creates directory 'foo'\", \"type\": \"string\"}}, \"required\": [\"command\", \"description\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"description\": \"Read a file or directory from the local filesystem. If the path does not exist, an error is returned.\\n\\nUsage:\\n- The filePath parameter should be an absolute path.\\n- By default, this tool returns up to 2000 lines from the start of the file.\\n- The offset parameter is the line number to start from (1-indexed).\\n- To read later sections, call this tool again with a larger offset.\\n- Use the grep tool to find specific content in large files or files with long lines.\\n- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.\\n- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents \\\"foo\\\\n\\\", you will receive \\\"1: foo\\\\n\\\". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.\\n- Any line longer than 2000 characters is truncated.\\n- Call this tool in parallel when you know there are multiple files you want to read.\\n- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.\\n- This tool can read image files and PDFs and return them as file attachments.\\n\", \"name\": \"read\", \"parameters\": {\"type\": \"object\", \"properties\": {\"filePath\": {\"description\": \"The absolute path to the file or directory to read\", \"type\": \"string\"}, \"offset\": {\"description\": \"The line number to start reading from (1-indexed)\", \"type\": \"number\"}, \"limit\": {\"description\": \"The maximum number of lines to read (defaults to 2000)\", \"type\": \"number\"}}, \"required\": [\"filePath\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"description\": \"- Fast file pattern matching tool that works with any codebase size\\n- Supports glob patterns like \\\"**/*.js\\\" or \\\"src/**/*.ts\\\"\\n- Returns matching file paths sorted by modification time\\n- Use this tool when you need to find files by name patterns\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.\\n\", \"name\": \"glob\", \"parameters\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"description\": \"The glob pattern to match files against\", \"type\": \"string\"}, \"path\": {\"description\": \"The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \\\"undefined\\\" or \\\"null\\\" - simply omit it for the default behavior. Must be a valid directory path if provided.\", \"type\": \"string\"}}, \"required\": [\"pattern\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"description\": \"- Fast content search tool that works with any codebase size\\n- Searches file contents using regular expressions\\n- Supports full regex syntax (eg. \\\"log.*Error\\\", \\\"function\\\\s+\\\\w+\\\", etc.)\\n- Filter files by pattern with the include parameter (eg. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\\n- Returns file paths and line numbers with at least one match sorted by modification time\\n- Use this tool when you need to find files containing specific patterns\\n- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n\", \"name\": \"grep\", \"parameters\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"description\": \"The regex pattern to search for in file contents\", \"type\": \"string\"}, \"path\": {\"description\": \"The directory to search in. Defaults to the current working directory.\", \"type\": \"string\"}, \"include\": {\"description\": \"File pattern to include in the search (e.g. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\", \"type\": \"string\"}}, \"required\": [\"pattern\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"description\": \"- Fetches content from a specified URL\\n- Takes a URL and optional format as input\\n- Fetches the URL content, converts to requested format (markdown by default)\\n- Returns the content in the specified format\\n- Use this tool when you need to retrieve and analyze web content\\n\\nUsage notes:\\n - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one.\\n - The URL must be a fully-formed valid URL\\n - HTTP URLs will be automatically upgraded to HTTPS\\n - Format options: \\\"markdown\\\" (default), \\\"text\\\", or \\\"html\\\"\\n - This tool is read-only and does not modify any files\\n - Results may be summarized if the content is very large\\n\", \"name\": \"webfetch\", \"parameters\": {\"type\": \"object\", \"properties\": {\"url\": {\"description\": \"The URL to fetch content from\", \"type\": \"string\"}, \"format\": {\"description\": \"The format to return the content in (text, markdown, or html). Defaults to markdown.\", \"default\": \"markdown\", \"type\": \"string\", \"enum\": [\"text\", \"markdown\", \"html\"]}, \"timeout\": {\"description\": \"Optional timeout in seconds (max 120)\", \"type\": \"number\"}}, \"required\": [\"url\", \"format\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"description\": \"Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available.\", \"name\": \"skill\", \"parameters\": {\"type\": \"object\", \"properties\": {\"name\": {\"description\": \"The name of the skill from available_skills\", \"type\": \"string\"}}, \"required\": [\"name\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"description\": \"Use the `apply_patch` tool to edit files. Your patch language is a stripped\\u2011down, file\\u2011oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high\\u2011level envelope:\\n\\n*** Begin Patch\\n[ one or more file sections ]\\n*** End Patch\\n\\nWithin that envelope, you get a sequence of file operations.\\nYou MUST include a header to specify the action you are taking.\\nEach operation starts with one of three headers:\\n\\n*** Add File: - create a new file. Every following line is a + line (the initial contents).\\n*** Delete File: - remove an existing file. Nothing follows.\\n*** Update File: - patch an existing file in place (optionally with a rename).\\n\\nExample patch:\\n\\n```\\n*** Begin Patch\\n*** Add File: hello.txt\\n+Hello world\\n*** Update File: src/app.py\\n*** Move to: src/main.py\\n@@ def greet():\\n-print(\\\"Hi\\\")\\n+print(\\\"Hello, world!\\\")\\n*** Delete File: obsolete.txt\\n*** End Patch\\n```\\n\\nIt is important to remember:\\n\\n- You must include a header with your intended action (Add/Delete/Update)\\n- You must prefix new lines with `+` even when creating a new file\\n\", \"name\": \"apply_patch\", \"parameters\": {\"type\": \"object\", \"properties\": {\"patchText\": {\"description\": \"The full patch text that describes all changes to be made\", \"type\": \"string\"}}, \"required\": [\"patchText\"], \"additionalProperties\": false}, \"strict\": false}], \"top_logprobs\": 0, \"top_p\": 1.0, \"truncation\": \"disabled\", \"usage\": {\"input_tokens\": 5381, \"input_tokens_details\": {\"cached_tokens\": 0}, \"output_tokens\": 115, \"output_tokens_details\": {\"reasoning_tokens\": 11}, \"total_tokens\": 5496}, \"user\": null, \"metadata\": {}}, \"sequence_number\": 16}\n\n", + "response_content_type": "text/event-stream; charset=utf-8", + "duration_ms": 2694 +} \ No newline at end of file diff --git a/internal/greyproxy/dissector/testdata/openai_1170.json b/internal/greyproxy/dissector/testdata/openai_1170.json new file mode 100644 index 0000000..c882b7c --- /dev/null +++ b/internal/greyproxy/dissector/testdata/openai_1170.json @@ -0,0 +1,11 @@ +{ + "id": 1170, + "container_name": "opencode", + "url": "https://api.openai.com/v1/responses", + "method": "POST", + "destination_host": "api.openai.com", + "request_body": "{\"model\": \"gpt-5.1\", \"input\": [{\"role\": \"developer\", \"content\": \"You are OpenCode, the best coding agent on the planet.\\n\\nYou are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.\\n\\n## Editing constraints\\n- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.\\n- Only add comments if they are necessary to make a non-obvious block easier to understand.\\n- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more ef... [truncated for test]\"}, {\"role\": \"user\", \"content\": [{\"type\": \"input_text\", \"text\": \"You are a research assistant. Use web tools to research what 'Greyhaven' refers to in the context of technology/companies, especially any connection to software, security, or developer tools. Identify the most likely organization or project relevant to a GitHub repo named 'greyproxy' owned by Monadical. Collect: (1) a short description of Greyhaven, (2) its main purpose or product, (3) any mentions of Monadical or connections to it. Return your findings in 5-8 concise bullet points.\"}]}, {\"type\": \"reasoning\", \"encrypted_content\": \"REDACTED_FOR_TEST\", \"summary\": []}, {\"type\": \"function_call\", \"call_id\": \"call_XNYBVYecjtpEPpG6mogKiZL7\", \"name\": \"webfetch\", \"arguments\": \"{\\\"url\\\":\\\"https://www.google.com/search?q=Greyhaven+software+security+developer+tools+Monadical\\\",\\\"format\\\":\\\"markdown\\\",\\\"timeout\\\":60}\"}, {\"type\": \"function_call_output\", \"call_id\": \"call_XNYBVYecjtpEPpG6mogKiZL7\", \"output\": \"Google Search\\n\\nHaz clic [aqu\\u00ed](/httpservice/retry/enablejs?sei=mRC7abbXO9mo1sQPjYKd8Qg) si no vuelves a acceder en pocos segundos.\\n\\nIf you're having trouble accessing Google Search, please\\u00a0[click here](/search?q=Greyhaven+software+security+developer+tools+Monadical&sca_esv=069bfcf63421705d&emsg=SG_REL&sei=mRC7abbXO9mo1sQPjYKd8Qg), or send\\u00a0[feedback](https://support.google.com/websearch).\"}], \"max_output_tokens\": 32000, \"text\": {\"verbosity\": \"low\"}, \"store\": false, \"include\": [\"reasoning.encrypted_content\"], \"prompt_cache_key\": \"ses_2fd479e84ffeWtEMMxa6aH6Smi\", \"reasoning\": {\"effort\": \"medium\", \"summary\": \"auto\"}, \"tools\": [{\"type\": \"function\", \"name\": \"bash\", \"description\": \"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\\n\\nAll commands run in /home/tito/code/monadical/greyproxy by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.\\n\\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\\n\\nBefore executing the command, please follow these steps:\\n\\n1. Directory Verification:\\n - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location\\n - For example, before running \\\"mkdir foo/bar\\\", first use `ls foo` to check that \\\"foo\\\" exists and is the intended parent directory\\n\\n2. Command Execution:\\n - Always quote file paths that contain spaces with double quotes (e.g., rm \\\"path with spaces/file.txt\\\")\\n - Examples of proper quoting:\\n - mkdir \\\"/Users/name/My Documents\\\" (correct)\\n - mkdir /Users/name/My Documents (incorrect - will fail)\\n - python \\\"/path/with spaces/script.py\\\" (correct)\\n - python /path/with spaces/script.py (incorrect - will fail)\\n - After ensuring proper quoting, execute the command.\\n - Capture the output of the command.\\n\\nUsage notes:\\n - The command argument is required.\\n - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).\\n - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.\\n - If the output exceeds 2000 lines or 51200 bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.\\n\\n - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:\\n - File search: Use Glob (NOT find or ls)\\n - Content search: Use Grep (NOT grep or rg)\\n - Read files: Use Read (NOT cat/head/tail)\\n - Edit files: Use Edit (NOT sed/awk)\\n - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead.\\n \\n Use workdir=\\\"/foo/bar\\\" with command: pytest tests\\n \\n \\n cd /foo/bar && pytest tests\\n \\n\\n# Committing changes with git\\n\\nOnly create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:\\n\\nGit Safety Protocol:\\n- NEVER update the git config\\n- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them\\n- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it\\n- NEVER run force push to main/master, warn the user if they request it\\n- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:\\n (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including\\n (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')\\n (3) Commit has NOT been pushed to remote (verify: git status shows \\\"Your branch is ahead\\\")\\n- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit\\n- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)\\n- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:\\n - Run a git status command to see all untracked files.\\n - Run a git diff command to see both staged and unstaged changes that will be committed.\\n - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\\n2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:\\n - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. \\\"add\\\" means a wholly new feature, \\\"update\\\" means an enhancement to an existing feature, \\\"fix\\\" means a bug fix, etc.).\\n - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files\\n - Draft a concise (1-2 sentences) commit message that focuses on the \\\"why\\\" rather than the \\\"what\\\"\\n - Ensure it accurately reflects the changes and their purpose\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:\\n - Add relevant untracked files to the staging area.\\n - Create the commit with a message\\n - Run git status after the commit completes to verify success.\\n Note: git status depends on the commit completing, so run it sequentially after the commit.\\n4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)\\n\\nImportant notes:\\n- NEVER run additional commands to read or explore code, besides git bash commands\\n- NEVER use the TodoWrite or Task tools\\n- DO NOT push to the remote repository unless the user explicitly asks you to do so\\n- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\\n- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\\n\\n# Creating pull requests\\nUse the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.\\n\\nIMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:\\n - Run a git status command to see all untracked files\\n - Run a git diff command to see both staged and unstaged changes that will be committed\\n - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\\n - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)\\n2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:\\n - Create new branch if needed\\n - Push to remote with -u flag if needed\\n - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\\n\\ngh pr create --title \\\"the pr title\\\" --body \\\"$(cat <<'EOF'\\n## Summary\\n<1-3 bullet points>\\n\\n\\nImportant:\\n- DO NOT use the TodoWrite or Task tools\\n- Return the PR URL when you're done, so the user can see it\\n\\n# Other common operations\\n- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments\\n\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"command\": {\"description\": \"The command to execute\", \"type\": \"string\"}, \"timeout\": {\"description\": \"Optional timeout in milliseconds\", \"type\": \"number\"}, \"workdir\": {\"description\": \"The working directory to run the command in. Defaults to /home/tito/code/monadical/greyproxy. Use this instead of 'cd' commands.\", \"type\": \"string\"}, \"description\": {\"description\": \"Clear, concise description of what this command does in 5-10 words. Examples:\\nInput: ls\\nOutput: Lists files in current directory\\n\\nInput: git status\\nOutput: Shows working tree status\\n\\nInput: npm install\\nOutput: Installs package dependencies\\n\\nInput: mkdir foo\\nOutput: Creates directory 'foo'\", \"type\": \"string\"}}, \"required\": [\"command\", \"description\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"name\": \"read\", \"description\": \"Read a file or directory from the local filesystem. If the path does not exist, an error is returned.\\n\\nUsage:\\n- The filePath parameter should be an absolute path.\\n- By default, this tool returns up to 2000 lines from the start of the file.\\n- The offset parameter is the line number to start from (1-indexed).\\n- To read later sections, call this tool again with a larger offset.\\n- Use the grep tool to find specific content in large files or files with long lines.\\n- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.\\n- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents \\\"foo\\\\n\\\", you will receive \\\"1: foo\\\\n\\\". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.\\n- Any line longer than 2000 characters is truncated.\\n- Call this tool in parallel when you know there are multiple files you want to read.\\n- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.\\n- This tool can read image files and PDFs and return them as file attachments.\\n\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"filePath\": {\"description\": \"The absolute path to the file or directory to read\", \"type\": \"string\"}, \"offset\": {\"description\": \"The line number to start reading from (1-indexed)\", \"type\": \"number\"}, \"limit\": {\"description\": \"The maximum number of lines to read (defaults to 2000)\", \"type\": \"number\"}}, \"required\": [\"filePath\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"name\": \"glob\", \"description\": \"- Fast file pattern matching tool that works with any codebase size\\n- Supports glob patterns like \\\"**/*.js\\\" or \\\"src/**/*.ts\\\"\\n- Returns matching file paths sorted by modification time\\n- Use this tool when you need to find files by name patterns\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.\\n\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"pattern\": {\"description\": \"The glob pattern to match files against\", \"type\": \"string\"}, \"path\": {\"description\": \"The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \\\"undefined\\\" or \\\"null\\\" - simply omit it for the default behavior. Must be a valid directory path if provided.\", \"type\": \"string\"}}, \"required\": [\"pattern\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"name\": \"grep\", \"description\": \"- Fast content search tool that works with any codebase size\\n- Searches file contents using regular expressions\\n- Supports full regex syntax (eg. \\\"log.*Error\\\", \\\"function\\\\s+\\\\w+\\\", etc.)\\n- Filter files by pattern with the include parameter (eg. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\\n- Returns file paths and line numbers with at least one match sorted by modification time\\n- Use this tool when you need to find files containing specific patterns\\n- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"pattern\": {\"description\": \"The regex pattern to search for in file contents\", \"type\": \"string\"}, \"path\": {\"description\": \"The directory to search in. Defaults to the current working directory.\", \"type\": \"string\"}, \"include\": {\"description\": \"File pattern to include in the search (e.g. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\", \"type\": \"string\"}}, \"required\": [\"pattern\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"name\": \"webfetch\", \"description\": \"- Fetches content from a specified URL\\n- Takes a URL and optional format as input\\n- Fetches the URL content, converts to requested format (markdown by default)\\n- Returns the content in the specified format\\n- Use this tool when you need to retrieve and analyze web content\\n\\nUsage notes:\\n - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one.\\n - The URL must be a fully-formed valid URL\\n - HTTP URLs will be automatically upgraded to HTTPS\\n - Format options: \\\"markdown\\\" (default), \\\"text\\\", or \\\"html\\\"\\n - This tool is read-only and does not modify any files\\n - Results may be summarized if the content is very large\\n\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"url\": {\"description\": \"The URL to fetch content from\", \"type\": \"string\"}, \"format\": {\"description\": \"The format to return the content in (text, markdown, or html). Defaults to markdown.\", \"default\": \"markdown\", \"type\": \"string\", \"enum\": [\"text\", \"markdown\", \"html\"]}, \"timeout\": {\"description\": \"Optional timeout in seconds (max 120)\", \"type\": \"number\"}}, \"required\": [\"url\", \"format\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"name\": \"skill\", \"description\": \"Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available.\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"name\": {\"description\": \"The name of the skill from available_skills\", \"type\": \"string\"}}, \"required\": [\"name\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"name\": \"apply_patch\", \"description\": \"Use the `apply_patch` tool to edit files. Your patch language is a stripped\\u2011down, file\\u2011oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high\\u2011level envelope:\\n\\n*** Begin Patch\\n[ one or more file sections ]\\n*** End Patch\\n\\nWithin that envelope, you get a sequence of file operations.\\nYou MUST include a header to specify the action you are taking.\\nEach operation starts with one of three headers:\\n\\n*** Add File: - create a new file. Every following line is a + line (the initial contents).\\n*** Delete File: - remove an existing file. Nothing follows.\\n*** Update File: - patch an existing file in place (optionally with a rename).\\n\\nExample patch:\\n\\n```\\n*** Begin Patch\\n*** Add File: hello.txt\\n+Hello world\\n*** Update File: src/app.py\\n*** Move to: src/main.py\\n@@ def greet():\\n-print(\\\"Hi\\\")\\n+print(\\\"Hello, world!\\\")\\n*** Delete File: obsolete.txt\\n*** End Patch\\n```\\n\\nIt is important to remember:\\n\\n- You must include a header with your intended action (Add/Delete/Update)\\n- You must prefix new lines with `+` even when creating a new file\\n\", \"parameters\": {\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"patchText\": {\"description\": \"The full patch text that describes all changes to be made\", \"type\": \"string\"}}, \"required\": [\"patchText\"], \"additionalProperties\": false}, \"strict\": false}], \"tool_choice\": \"auto\", \"stream\": true}", + "response_body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_06900299c02b8a390169bb109cac3881a29d765492b6575ce5\",\"object\":\"response\",\"created_at\":1773867164,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":32000,\"max_tool_calls\":null,\"model\":\"gpt-5.1-2025-11-13\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"ses_2fd479e84ffeWtEMMxa6aH6Smi\",\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\\n\\nAll commands run in /home/tito/code/monadical/greyproxy by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.\\n\\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\\n\\nBefore executing the command, please follow these steps:\\n\\n1. Directory Verification:\\n - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location\\n - For example, before running \\\"mkdir foo/bar\\\", first use `ls foo` to check that \\\"foo\\\" exists and is the intended parent directory\\n\\n2. Command Execution:\\n - Always quote file paths that contain spaces with double quotes (e.g., rm \\\"path with spaces/file.txt\\\")\\n - Examples of proper quoting:\\n - mkdir \\\"/Users/name/My Documents\\\" (correct)\\n - mkdir /Users/name/My Documents (incorrect - will fail)\\n - python \\\"/path/with spaces/script.py\\\" (correct)\\n - python /path/with spaces/script.py (incorrect - will fail)\\n - After ensuring proper quoting, execute the command.\\n - Capture the output of the command.\\n\\nUsage notes:\\n - The command argument is required.\\n - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).\\n - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.\\n - If the output exceeds 2000 lines or 51200 bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.\\n\\n - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:\\n - File search: Use Glob (NOT find or ls)\\n - Content search: Use Grep (NOT grep or rg)\\n - Read files: Use Read (NOT cat/head/tail)\\n - Edit files: Use Edit (NOT sed/awk)\\n - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead.\\n \\n Use workdir=\\\"/foo/bar\\\" with command: pytest tests\\n \\n \\n cd /foo/bar && pytest tests\\n \\n\\n# Committing changes with git\\n\\nOnly create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:\\n\\nGit Safety Protocol:\\n- NEVER update the git config\\n- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them\\n- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it\\n- NEVER run force push to main/master, warn the user if they request it\\n- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:\\n (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including\\n (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')\\n (3) Commit has NOT been pushed to remote (verify: git status shows \\\"Your branch is ahead\\\")\\n- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit\\n- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)\\n- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:\\n - Run a git status command to see all untracked files.\\n - Run a git diff command to see both staged and unstaged changes that will be committed.\\n - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\\n2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:\\n - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. \\\"add\\\" means a wholly new feature, \\\"update\\\" means an enhancement to an existing feature, \\\"fix\\\" means a bug fix, etc.).\\n - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files\\n - Draft a concise (1-2 sentences) commit message that focuses on the \\\"why\\\" rather than the \\\"what\\\"\\n - Ensure it accurately reflects the changes and their purpose\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:\\n - Add relevant untracked files to the staging area.\\n - Create the commit with a message\\n - Run git status after the commit completes to verify success.\\n Note: git status depends on the commit completing, so run it sequentially after the commit.\\n4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)\\n\\nImportant notes:\\n- NEVER run additional commands to read or explore code, besides git bash commands\\n- NEVER use the TodoWrite or Task tools\\n- DO NOT push to the remote repository unless the user explicitly asks you to do so\\n- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\\n- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\\n\\n# Creating pull requests\\nUse the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.\\n\\nIMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:\\n - Run a git status command to see all untracked files\\n - Run a git diff command to see both staged and unstaged changes that will be committed\\n - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\\n - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)\\n2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:\\n - Create new branch if needed\\n - Push to remote with -u flag if needed\\n - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\\n\\ngh pr create --title \\\"the pr title\\\" --body \\\"$(cat <<'EOF'\\n## Summary\\n<1-3 bullet points>\\n\\n\\nImportant:\\n- DO NOT use the TodoWrite or Task tools\\n- Return the PR URL when you're done, so the user can see it\\n\\n# Other common operations\\n- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments\\n\",\"name\":\"bash\",\"parameters\":{\"type\":\"object\",\"properties\":{\"command\":{\"description\":\"The command to execute\",\"type\":\"string\"},\"timeout\":{\"description\":\"Optional timeout in milliseconds\",\"type\":\"number\"},\"workdir\":{\"description\":\"The working directory to run the command in. Defaults to /home/tito/code/monadical/greyproxy. Use this instead of 'cd' commands.\",\"type\":\"string\"},\"description\":{\"description\":\"Clear, concise description of what this command does in 5-10 words. Examples:\\nInput: ls\\nOutput: Lists files in current directory\\n\\nInput: git status\\nOutput: Shows working tree status\\n\\nInput: npm install\\nOutput: Installs package dependencies\\n\\nInput: mkdir foo\\nOutput: Creates directory 'foo'\",\"type\":\"string\"}},\"required\":[\"command\",\"description\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"Read a file or directory from the local filesystem. If the path does not exist, an error is returned.\\n\\nUsage:\\n- The filePath parameter should be an absolute path.\\n- By default, this tool returns up to 2000 lines from the start of the file.\\n- The offset parameter is the line number to start from (1-indexed).\\n- To read later sections, call this tool again with a larger offset.\\n- Use the grep tool to find specific content in large files or files with long lines.\\n- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.\\n- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents \\\"foo\\\\n\\\", you will receive \\\"1: foo\\\\n\\\". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.\\n- Any line longer than 2000 characters is truncated.\\n- Call this tool in parallel when you know there are multiple files you want to read.\\n- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.\\n- This tool can read image files and PDFs and return them as file attachments.\\n\",\"name\":\"read\",\"parameters\":{\"type\":\"object\",\"properties\":{\"filePath\":{\"description\":\"The absolute path to the file or directory to read\",\"type\":\"string\"},\"offset\":{\"description\":\"The line number to start reading from (1-indexed)\",\"type\":\"number\"},\"limit\":{\"description\":\"The maximum number of lines to read (defaults to 2000)\",\"type\":\"number\"}},\"required\":[\"filePath\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"- Fast file pattern matching tool that works with any codebase size\\n- Supports glob patterns like \\\"**/*.js\\\" or \\\"src/**/*.ts\\\"\\n- Returns matching file paths sorted by modification time\\n- Use this tool when you need to find files by name patterns\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.\\n\",\"name\":\"glob\",\"parameters\":{\"type\":\"object\",\"properties\":{\"pattern\":{\"description\":\"The glob pattern to match files against\",\"type\":\"string\"},\"path\":{\"description\":\"The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \\\"undefined\\\" or \\\"null\\\" - simply omit it for the default behavior. Must be a valid directory path if provided.\",\"type\":\"string\"}},\"required\":[\"pattern\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"- Fast content search tool that works with any codebase size\\n- Searches file contents using regular expressions\\n- Supports full regex syntax (eg. \\\"log.*Error\\\", \\\"function\\\\s+\\\\w+\\\", etc.)\\n- Filter files by pattern with the include parameter (eg. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\\n- Returns file paths and line numbers with at least one match sorted by modification time\\n- Use this tool when you need to find files containing specific patterns\\n- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n\",\"name\":\"grep\",\"parameters\":{\"type\":\"object\",\"properties\":{\"pattern\":{\"description\":\"The regex pattern to search for in file contents\",\"type\":\"string\"},\"path\":{\"description\":\"The directory to search in. Defaults to the current working directory.\",\"type\":\"string\"},\"include\":{\"description\":\"File pattern to include in the search (e.g. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\",\"type\":\"string\"}},\"required\":[\"pattern\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"- Fetches content from a specified URL\\n- Takes a URL and optional format as input\\n- Fetches the URL content, converts to requested format (markdown by default)\\n- Returns the content in the specified format\\n- Use this tool when you need to retrieve and analyze web content\\n\\nUsage notes:\\n - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one.\\n - The URL must be a fully-formed valid URL\\n - HTTP URLs will be automatically upgraded to HTTPS\\n - Format options: \\\"markdown\\\" (default), \\\"text\\\", or \\\"html\\\"\\n - This tool is read-only and does not modify any files\\n - Results may be summarized if the content is very large\\n\",\"name\":\"webfetch\",\"parameters\":{\"type\":\"object\",\"properties\":{\"url\":{\"description\":\"The URL to fetch content from\",\"type\":\"string\"},\"format\":{\"description\":\"The format to return the content in (text, markdown, or html). Defaults to markdown.\",\"default\":\"markdown\",\"type\":\"string\",\"enum\":[\"text\",\"markdown\",\"html\"]},\"timeout\":{\"description\":\"Optional timeout in seconds (max 120)\",\"type\":\"number\"}},\"required\":[\"url\",\"format\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available.\",\"name\":\"skill\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"description\":\"The name of the skill from available_skills\",\"type\":\"string\"}},\"required\":[\"name\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"Use the `apply_patch` tool to edit files. Your patch language is a stripped\u2011down, file\u2011oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high\u2011level envelope:\\n\\n*** Begin Patch\\n[ one or more file sections ]\\n*** End Patch\\n\\nWithin that envelope, you get a sequence of file operations.\\nYou MUST include a header to specify the action you are taking.\\nEach operation starts with one of three headers:\\n\\n*** Add File: - create a new file. Every following line is a + line (the initial contents).\\n*** Delete File: - remove an existing file. Nothing follows.\\n*** Update File: - patch an existing file in place (optionally with a rename).\\n\\nExample patch:\\n\\n```\\n*** Begin Patch\\n*** Add File: hello.txt\\n+Hello world\\n*** Update File: src/app.py\\n*** Move to: src/main.py\\n@@ def greet():\\n-print(\\\"Hi\\\")\\n+print(\\\"Hello, world!\\\")\\n*** Delete File: obsolete.txt\\n*** End Patch\\n```\\n\\nIt is important to remember:\\n\\n- You must include a header with your intended action (Add/Delete/Update)\\n- You must prefix new lines with `+` even when creating a new file\\n\",\"name\":\"apply_patch\",\"parameters\":{\"type\":\"object\",\"properties\":{\"patchText\":{\"description\":\"The full patch text that describes all changes to be made\",\"type\":\"string\"}},\"required\":[\"patchText\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_06900299c02b8a390169bb109cac3881a29d765492b6575ce5\",\"object\":\"response\",\"created_at\":1773867164,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":32000,\"max_tool_calls\":null,\"model\":\"gpt-5.1-2025-11-13\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"ses_2fd479e84ffeWtEMMxa6aH6Smi\",\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\\n\\nAll commands run in /home/tito/code/monadical/greyproxy by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.\\n\\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\\n\\nBefore executing the command, please follow these steps:\\n\\n1. Directory Verification:\\n - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location\\n - For example, before running \\\"mkdir foo/bar\\\", first use `ls foo` to check that \\\"foo\\\" exists and is the intended parent directory\\n\\n2. Command Execution:\\n - Always quote file paths that contain spaces with double quotes (e.g., rm \\\"path with spaces/file.txt\\\")\\n - Examples of proper quoting:\\n - mkdir \\\"/Users/name/My Documents\\\" (correct)\\n - mkdir /Users/name/My Documents (incorrect - will fail)\\n - python \\\"/path/with spaces/script.py\\\" (correct)\\n - python /path/with spaces/script.py (incorrect - will fail)\\n - After ensuring proper quoting, execute the command.\\n - Capture the output of the command.\\n\\nUsage notes:\\n - The command argument is required.\\n - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).\\n - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.\\n - If the output exceeds 2000 lines or 51200 bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.\\n\\n - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:\\n - File search: Use Glob (NOT find or ls)\\n - Content search: Use Grep (NOT grep or rg)\\n - Read files: Use Read (NOT cat/head/tail)\\n - Edit files: Use Edit (NOT sed/awk)\\n - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead.\\n \\n Use workdir=\\\"/foo/bar\\\" with command: pytest tests\\n \\n \\n cd /foo/bar && pytest tests\\n \\n\\n# Committing changes with git\\n\\nOnly create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:\\n\\nGit Safety Protocol:\\n- NEVER update the git config\\n- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them\\n- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it\\n- NEVER run force push to main/master, warn the user if they request it\\n- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:\\n (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including\\n (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')\\n (3) Commit has NOT been pushed to remote (verify: git status shows \\\"Your branch is ahead\\\")\\n- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit\\n- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)\\n- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:\\n - Run a git status command to see all untracked files.\\n - Run a git diff command to see both staged and unstaged changes that will be committed.\\n - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\\n2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:\\n - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. \\\"add\\\" means a wholly new feature, \\\"update\\\" means an enhancement to an existing feature, \\\"fix\\\" means a bug fix, etc.).\\n - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files\\n - Draft a concise (1-2 sentences) commit message that focuses on the \\\"why\\\" rather than the \\\"what\\\"\\n - Ensure it accurately reflects the changes and their purpose\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:\\n - Add relevant untracked files to the staging area.\\n - Create the commit with a message\\n - Run git status after the commit completes to verify success.\\n Note: git status depends on the commit completing, so run it sequentially after the commit.\\n4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)\\n\\nImportant notes:\\n- NEVER run additional commands to read or explore code, besides git bash commands\\n- NEVER use the TodoWrite or Task tools\\n- DO NOT push to the remote repository unless the user explicitly asks you to do so\\n- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\\n- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\\n\\n# Creating pull requests\\nUse the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.\\n\\nIMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:\\n - Run a git status command to see all untracked files\\n - Run a git diff command to see both staged and unstaged changes that will be committed\\n - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\\n - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)\\n2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:\\n - Create new branch if needed\\n - Push to remote with -u flag if needed\\n - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\\n\\ngh pr create --title \\\"the pr title\\\" --body \\\"$(cat <<'EOF'\\n## Summary\\n<1-3 bullet points>\\n\\n\\nImportant:\\n- DO NOT use the TodoWrite or Task tools\\n- Return the PR URL when you're done, so the user can see it\\n\\n# Other common operations\\n- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments\\n\",\"name\":\"bash\",\"parameters\":{\"type\":\"object\",\"properties\":{\"command\":{\"description\":\"The command to execute\",\"type\":\"string\"},\"timeout\":{\"description\":\"Optional timeout in milliseconds\",\"type\":\"number\"},\"workdir\":{\"description\":\"The working directory to run the command in. Defaults to /home/tito/code/monadical/greyproxy. Use this instead of 'cd' commands.\",\"type\":\"string\"},\"description\":{\"description\":\"Clear, concise description of what this command does in 5-10 words. Examples:\\nInput: ls\\nOutput: Lists files in current directory\\n\\nInput: git status\\nOutput: Shows working tree status\\n\\nInput: npm install\\nOutput: Installs package dependencies\\n\\nInput: mkdir foo\\nOutput: Creates directory 'foo'\",\"type\":\"string\"}},\"required\":[\"command\",\"description\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"Read a file or directory from the local filesystem. If the path does not exist, an error is returned.\\n\\nUsage:\\n- The filePath parameter should be an absolute path.\\n- By default, this tool returns up to 2000 lines from the start of the file.\\n- The offset parameter is the line number to start from (1-indexed).\\n- To read later sections, call this tool again with a larger offset.\\n- Use the grep tool to find specific content in large files or files with long lines.\\n- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.\\n- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents \\\"foo\\\\n\\\", you will receive \\\"1: foo\\\\n\\\". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.\\n- Any line longer than 2000 characters is truncated.\\n- Call this tool in parallel when you know there are multiple files you want to read.\\n- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.\\n- This tool can read image files and PDFs and return them as file attachments.\\n\",\"name\":\"read\",\"parameters\":{\"type\":\"object\",\"properties\":{\"filePath\":{\"description\":\"The absolute path to the file or directory to read\",\"type\":\"string\"},\"offset\":{\"description\":\"The line number to start reading from (1-indexed)\",\"type\":\"number\"},\"limit\":{\"description\":\"The maximum number of lines to read (defaults to 2000)\",\"type\":\"number\"}},\"required\":[\"filePath\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"- Fast file pattern matching tool that works with any codebase size\\n- Supports glob patterns like \\\"**/*.js\\\" or \\\"src/**/*.ts\\\"\\n- Returns matching file paths sorted by modification time\\n- Use this tool when you need to find files by name patterns\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.\\n\",\"name\":\"glob\",\"parameters\":{\"type\":\"object\",\"properties\":{\"pattern\":{\"description\":\"The glob pattern to match files against\",\"type\":\"string\"},\"path\":{\"description\":\"The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \\\"undefined\\\" or \\\"null\\\" - simply omit it for the default behavior. Must be a valid directory path if provided.\",\"type\":\"string\"}},\"required\":[\"pattern\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"- Fast content search tool that works with any codebase size\\n- Searches file contents using regular expressions\\n- Supports full regex syntax (eg. \\\"log.*Error\\\", \\\"function\\\\s+\\\\w+\\\", etc.)\\n- Filter files by pattern with the include parameter (eg. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\\n- Returns file paths and line numbers with at least one match sorted by modification time\\n- Use this tool when you need to find files containing specific patterns\\n- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n\",\"name\":\"grep\",\"parameters\":{\"type\":\"object\",\"properties\":{\"pattern\":{\"description\":\"The regex pattern to search for in file contents\",\"type\":\"string\"},\"path\":{\"description\":\"The directory to search in. Defaults to the current working directory.\",\"type\":\"string\"},\"include\":{\"description\":\"File pattern to include in the search (e.g. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\",\"type\":\"string\"}},\"required\":[\"pattern\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"- Fetches content from a specified URL\\n- Takes a URL and optional format as input\\n- Fetches the URL content, converts to requested format (markdown by default)\\n- Returns the content in the specified format\\n- Use this tool when you need to retrieve and analyze web content\\n\\nUsage notes:\\n - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one.\\n - The URL must be a fully-formed valid URL\\n - HTTP URLs will be automatically upgraded to HTTPS\\n - Format options: \\\"markdown\\\" (default), \\\"text\\\", or \\\"html\\\"\\n - This tool is read-only and does not modify any files\\n - Results may be summarized if the content is very large\\n\",\"name\":\"webfetch\",\"parameters\":{\"type\":\"object\",\"properties\":{\"url\":{\"description\":\"The URL to fetch content from\",\"type\":\"string\"},\"format\":{\"description\":\"The format to return the content in (text, markdown, or html). Defaults to markdown.\",\"default\":\"markdown\",\"type\":\"string\",\"enum\":[\"text\",\"markdown\",\"html\"]},\"timeout\":{\"description\":\"Optional timeout in seconds (max 120)\",\"type\":\"number\"}},\"required\":[\"url\",\"format\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available.\",\"name\":\"skill\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"description\":\"The name of the skill from available_skills\",\"type\":\"string\"}},\"required\":[\"name\"],\"additionalProperties\":false},\"strict\":false},{\"type\":\"function\",\"description\":\"Use the `apply_patch` tool to edit files. Your patch language is a stripped\u2011down, file\u2011oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high\u2011level envelope:\\n\\n*** Begin Patch\\n[ one or more file sections ]\\n*** End Patch\\n\\nWithin that envelope, you get a sequence of file operations.\\nYou MUST include a header to specify the action you are taking.\\nEach operation starts with one of three headers:\\n\\n*** Add File: - create a new file. Every following line is a + line (the initial contents).\\n*** Delete File: - remove an existing file. Nothing follows.\\n*** Update File: - patch an existing file in place (optionally with a rename).\\n\\nExample patch:\\n\\n```\\n*** Begin Patch\\n*** Add File: hello.txt\\n+Hello world\\n*** Update File: src/app.py\\n*** Move to: src/main.py\\n@@ def greet():\\n-print(\\\"Hi\\\")\\n+print(\\\"Hello, world!\\\")\\n*** Delete File: obsolete.txt\\n*** End Patch\\n```\\n\\nIt is important to remember:\\n\\n- You must include a header with your intended action (Add/Delete/Update)\\n- You must prefix new lines with `+` even when creating a new file\\n\",\"name\":\"apply_patch\",\"parameters\":{\"type\":\"object\",\"properties\":{\"patchText\":{\"description\":\"The full patch text that describes all changes to be made\",\"type\":\"string\"}},\"required\":[\"patchText\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\": \"response.output_item.added\", \"item\": {\"id\": \"rs_06900299c02b8a390169bb109d675881a2b21e5be1afbe19e7\", \"type\": \"reasoning\", \"encrypted_content\": \"REDACTED_FOR_TEST\", \"summary\": []}, \"output_index\": 0, \"sequence_number\": 2}\n\nevent: response.output_item.done\ndata: {\"type\": \"response.output_item.done\", \"item\": {\"id\": \"rs_06900299c02b8a390169bb109d675881a2b21e5be1afbe19e7\", \"type\": \"reasoning\", \"encrypted_content\": \"REDACTED_FOR_TEST\", \"summary\": []}, \"output_index\": 0, \"sequence_number\": 3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_cTm2KHlU6j4cXqGE4L1e0Fp7\",\"name\":\"webfetch\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"iHGPWITAqaQOQ5\",\"output_index\":1,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"url\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"nhKR8yzjqjy7d\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"tBMNJI8aCNLU4\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"https\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"Sq0Lhq0y6nf\",\"output_index\":1,\"sequence_number\":8}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"://\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"QygZcHFItXDr8\",\"output_index\":1,\"sequence_number\":9}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"duck\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"uuJrzfzLFstn\",\"output_index\":1,\"sequence_number\":10}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"duck\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"ehE3vdtKlAJo\",\"output_index\":1,\"sequence_number\":11}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"go\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"17VqiCgwWfvN4z\",\"output_index\":1,\"sequence_number\":12}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\".com\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"wKNRKfEYzTPq\",\"output_index\":1,\"sequence_number\":13}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"/html\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"lS0EMMpAzdI\",\"output_index\":1,\"sequence_number\":14}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"/?\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"NbN6lmbe5B7EM3\",\"output_index\":1,\"sequence_number\":15}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"q\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"Ecpbi96LEB5Hhsg\",\"output_index\":1,\"sequence_number\":16}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"=\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"lNZflPcpleGV5aF\",\"output_index\":1,\"sequence_number\":17}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Grey\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"argoF7P99Eil\",\"output_index\":1,\"sequence_number\":18}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"haven\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"VZR1TTQvvJB\",\"output_index\":1,\"sequence_number\":19}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"+\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"WBg228UtSq5br5K\",\"output_index\":1,\"sequence_number\":20}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"software\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"SEeN3Ztc\",\"output_index\":1,\"sequence_number\":21}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"+\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"XJnJKKFlbAYyefD\",\"output_index\":1,\"sequence_number\":22}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"security\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"ytGoLCG1\",\"output_index\":1,\"sequence_number\":23}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"+\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"fSELpUMg0dBcm3c\",\"output_index\":1,\"sequence_number\":24}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"developer\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"CixR2jd\",\"output_index\":1,\"sequence_number\":25}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"+\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"E7ubcs187dsgYPh\",\"output_index\":1,\"sequence_number\":26}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"tools\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"cyxtm4ifLlv\",\"output_index\":1,\"sequence_number\":27}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"+\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"Uk70Oftqz9CWgGS\",\"output_index\":1,\"sequence_number\":28}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Monad\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"NN9iYdkBGk7\",\"output_index\":1,\"sequence_number\":29}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"ical\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"jSfFquhPozA4\",\"output_index\":1,\"sequence_number\":30}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\",\\\"\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"pBln9wbtdpIzS\",\"output_index\":1,\"sequence_number\":31}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"format\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"QichSaFwC8\",\"output_index\":1,\"sequence_number\":32}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"OzUP8b2mzuxdr\",\"output_index\":1,\"sequence_number\":33}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"html\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"QSsRzFt00LYJ\",\"output_index\":1,\"sequence_number\":34}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\",\\\"\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"OLUjwbwrzxnRq\",\"output_index\":1,\"sequence_number\":35}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"timeout\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"bv6RaXoEV\",\"output_index\":1,\"sequence_number\":36}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"EEj5RWsX8IWgOL\",\"output_index\":1,\"sequence_number\":37}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"60\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"QJHVkP7nYWOzKF\",\"output_index\":1,\"sequence_number\":38}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"}\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"obfuscation\":\"3Uw6WASBZupbh2k\",\"output_index\":1,\"sequence_number\":39}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"url\\\":\\\"https://duckduckgo.com/html/?q=Greyhaven+software+security+developer+tools+Monadical\\\",\\\"format\\\":\\\"html\\\",\\\"timeout\\\":60}\",\"item_id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"output_index\":1,\"sequence_number\":40}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"url\\\":\\\"https://duckduckgo.com/html/?q=Greyhaven+software+security+developer+tools+Monadical\\\",\\\"format\\\":\\\"html\\\",\\\"timeout\\\":60}\",\"call_id\":\"call_cTm2KHlU6j4cXqGE4L1e0Fp7\",\"name\":\"webfetch\"},\"output_index\":1,\"sequence_number\":41}\n\nevent: response.completed\ndata: {\"type\": \"response.completed\", \"response\": {\"id\": \"resp_06900299c02b8a390169bb109cac3881a29d765492b6575ce5\", \"object\": \"response\", \"created_at\": 1773867164, \"status\": \"completed\", \"background\": false, \"completed_at\": 1773867166, \"error\": null, \"frequency_penalty\": 0.0, \"incomplete_details\": null, \"instructions\": null, \"max_output_tokens\": 32000, \"max_tool_calls\": null, \"model\": \"gpt-5.1-2025-11-13\", \"output\": [{\"id\": \"rs_06900299c02b8a390169bb109d675881a2b21e5be1afbe19e7\", \"type\": \"reasoning\", \"encrypted_content\": \"REDACTED_FOR_TEST\", \"summary\": []}, {\"id\": \"fc_06900299c02b8a390169bb109e2f9481a29b1007530858ee89\", \"type\": \"function_call\", \"status\": \"completed\", \"arguments\": \"{\\\"url\\\":\\\"https://duckduckgo.com/html/?q=Greyhaven+software+security+developer+tools+Monadical\\\",\\\"format\\\":\\\"html\\\",\\\"timeout\\\":60}\", \"call_id\": \"call_cTm2KHlU6j4cXqGE4L1e0Fp7\", \"name\": \"webfetch\"}], \"parallel_tool_calls\": true, \"presence_penalty\": 0.0, \"previous_response_id\": null, \"prompt_cache_key\": \"ses_2fd479e84ffeWtEMMxa6aH6Smi\", \"prompt_cache_retention\": null, \"reasoning\": {\"effort\": \"medium\", \"summary\": \"detailed\"}, \"safety_identifier\": null, \"service_tier\": \"default\", \"store\": false, \"temperature\": 1.0, \"text\": {\"format\": {\"type\": \"text\"}, \"verbosity\": \"low\"}, \"tool_choice\": \"auto\", \"tools\": [{\"type\": \"function\", \"description\": \"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\\n\\nAll commands run in /home/tito/code/monadical/greyproxy by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.\\n\\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\\n\\nBefore executing the command, please follow these steps:\\n\\n1. Directory Verification:\\n - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location\\n - For example, before running \\\"mkdir foo/bar\\\", first use `ls foo` to check that \\\"foo\\\" exists and is the intended parent directory\\n\\n2. Command Execution:\\n - Always quote file paths that contain spaces with double quotes (e.g., rm \\\"path with spaces/file.txt\\\")\\n - Examples of proper quoting:\\n - mkdir \\\"/Users/name/My Documents\\\" (correct)\\n - mkdir /Users/name/My Documents (incorrect - will fail)\\n - python \\\"/path/with spaces/script.py\\\" (correct)\\n - python /path/with spaces/script.py (incorrect - will fail)\\n - After ensuring proper quoting, execute the command.\\n - Capture the output of the command.\\n\\nUsage notes:\\n - The command argument is required.\\n - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).\\n - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.\\n - If the output exceeds 2000 lines or 51200 bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.\\n\\n - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:\\n - File search: Use Glob (NOT find or ls)\\n - Content search: Use Grep (NOT grep or rg)\\n - Read files: Use Read (NOT cat/head/tail)\\n - Edit files: Use Edit (NOT sed/awk)\\n - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead.\\n \\n Use workdir=\\\"/foo/bar\\\" with command: pytest tests\\n \\n \\n cd /foo/bar && pytest tests\\n \\n\\n# Committing changes with git\\n\\nOnly create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:\\n\\nGit Safety Protocol:\\n- NEVER update the git config\\n- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them\\n- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it\\n- NEVER run force push to main/master, warn the user if they request it\\n- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:\\n (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including\\n (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')\\n (3) Commit has NOT been pushed to remote (verify: git status shows \\\"Your branch is ahead\\\")\\n- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit\\n- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)\\n- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:\\n - Run a git status command to see all untracked files.\\n - Run a git diff command to see both staged and unstaged changes that will be committed.\\n - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\\n2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:\\n - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. \\\"add\\\" means a wholly new feature, \\\"update\\\" means an enhancement to an existing feature, \\\"fix\\\" means a bug fix, etc.).\\n - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files\\n - Draft a concise (1-2 sentences) commit message that focuses on the \\\"why\\\" rather than the \\\"what\\\"\\n - Ensure it accurately reflects the changes and their purpose\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:\\n - Add relevant untracked files to the staging area.\\n - Create the commit with a message\\n - Run git status after the commit completes to verify success.\\n Note: git status depends on the commit completing, so run it sequentially after the commit.\\n4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)\\n\\nImportant notes:\\n- NEVER run additional commands to read or explore code, besides git bash commands\\n- NEVER use the TodoWrite or Task tools\\n- DO NOT push to the remote repository unless the user explicitly asks you to do so\\n- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\\n- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\\n\\n# Creating pull requests\\nUse the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.\\n\\nIMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\\n\\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:\\n - Run a git status command to see all untracked files\\n - Run a git diff command to see both staged and unstaged changes that will be committed\\n - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\\n - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)\\n2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary\\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:\\n - Create new branch if needed\\n - Push to remote with -u flag if needed\\n - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\\n\\ngh pr create --title \\\"the pr title\\\" --body \\\"$(cat <<'EOF'\\n## Summary\\n<1-3 bullet points>\\n\\n\\nImportant:\\n- DO NOT use the TodoWrite or Task tools\\n- Return the PR URL when you're done, so the user can see it\\n\\n# Other common operations\\n- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments\\n\", \"name\": \"bash\", \"parameters\": {\"type\": \"object\", \"properties\": {\"command\": {\"description\": \"The command to execute\", \"type\": \"string\"}, \"timeout\": {\"description\": \"Optional timeout in milliseconds\", \"type\": \"number\"}, \"workdir\": {\"description\": \"The working directory to run the command in. Defaults to /home/tito/code/monadical/greyproxy. Use this instead of 'cd' commands.\", \"type\": \"string\"}, \"description\": {\"description\": \"Clear, concise description of what this command does in 5-10 words. Examples:\\nInput: ls\\nOutput: Lists files in current directory\\n\\nInput: git status\\nOutput: Shows working tree status\\n\\nInput: npm install\\nOutput: Installs package dependencies\\n\\nInput: mkdir foo\\nOutput: Creates directory 'foo'\", \"type\": \"string\"}}, \"required\": [\"command\", \"description\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"description\": \"Read a file or directory from the local filesystem. If the path does not exist, an error is returned.\\n\\nUsage:\\n- The filePath parameter should be an absolute path.\\n- By default, this tool returns up to 2000 lines from the start of the file.\\n- The offset parameter is the line number to start from (1-indexed).\\n- To read later sections, call this tool again with a larger offset.\\n- Use the grep tool to find specific content in large files or files with long lines.\\n- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.\\n- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents \\\"foo\\\\n\\\", you will receive \\\"1: foo\\\\n\\\". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.\\n- Any line longer than 2000 characters is truncated.\\n- Call this tool in parallel when you know there are multiple files you want to read.\\n- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.\\n- This tool can read image files and PDFs and return them as file attachments.\\n\", \"name\": \"read\", \"parameters\": {\"type\": \"object\", \"properties\": {\"filePath\": {\"description\": \"The absolute path to the file or directory to read\", \"type\": \"string\"}, \"offset\": {\"description\": \"The line number to start reading from (1-indexed)\", \"type\": \"number\"}, \"limit\": {\"description\": \"The maximum number of lines to read (defaults to 2000)\", \"type\": \"number\"}}, \"required\": [\"filePath\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"description\": \"- Fast file pattern matching tool that works with any codebase size\\n- Supports glob patterns like \\\"**/*.js\\\" or \\\"src/**/*.ts\\\"\\n- Returns matching file paths sorted by modification time\\n- Use this tool when you need to find files by name patterns\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.\\n\", \"name\": \"glob\", \"parameters\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"description\": \"The glob pattern to match files against\", \"type\": \"string\"}, \"path\": {\"description\": \"The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \\\"undefined\\\" or \\\"null\\\" - simply omit it for the default behavior. Must be a valid directory path if provided.\", \"type\": \"string\"}}, \"required\": [\"pattern\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"description\": \"- Fast content search tool that works with any codebase size\\n- Searches file contents using regular expressions\\n- Supports full regex syntax (eg. \\\"log.*Error\\\", \\\"function\\\\s+\\\\w+\\\", etc.)\\n- Filter files by pattern with the include parameter (eg. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\\n- Returns file paths and line numbers with at least one match sorted by modification time\\n- Use this tool when you need to find files containing specific patterns\\n- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.\\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\\n\", \"name\": \"grep\", \"parameters\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"description\": \"The regex pattern to search for in file contents\", \"type\": \"string\"}, \"path\": {\"description\": \"The directory to search in. Defaults to the current working directory.\", \"type\": \"string\"}, \"include\": {\"description\": \"File pattern to include in the search (e.g. \\\"*.js\\\", \\\"*.{ts,tsx}\\\")\", \"type\": \"string\"}}, \"required\": [\"pattern\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"description\": \"- Fetches content from a specified URL\\n- Takes a URL and optional format as input\\n- Fetches the URL content, converts to requested format (markdown by default)\\n- Returns the content in the specified format\\n- Use this tool when you need to retrieve and analyze web content\\n\\nUsage notes:\\n - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one.\\n - The URL must be a fully-formed valid URL\\n - HTTP URLs will be automatically upgraded to HTTPS\\n - Format options: \\\"markdown\\\" (default), \\\"text\\\", or \\\"html\\\"\\n - This tool is read-only and does not modify any files\\n - Results may be summarized if the content is very large\\n\", \"name\": \"webfetch\", \"parameters\": {\"type\": \"object\", \"properties\": {\"url\": {\"description\": \"The URL to fetch content from\", \"type\": \"string\"}, \"format\": {\"description\": \"The format to return the content in (text, markdown, or html). Defaults to markdown.\", \"default\": \"markdown\", \"type\": \"string\", \"enum\": [\"text\", \"markdown\", \"html\"]}, \"timeout\": {\"description\": \"Optional timeout in seconds (max 120)\", \"type\": \"number\"}}, \"required\": [\"url\", \"format\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"description\": \"Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available.\", \"name\": \"skill\", \"parameters\": {\"type\": \"object\", \"properties\": {\"name\": {\"description\": \"The name of the skill from available_skills\", \"type\": \"string\"}}, \"required\": [\"name\"], \"additionalProperties\": false}, \"strict\": false}, {\"type\": \"function\", \"description\": \"Use the `apply_patch` tool to edit files. Your patch language is a stripped\\u2011down, file\\u2011oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high\\u2011level envelope:\\n\\n*** Begin Patch\\n[ one or more file sections ]\\n*** End Patch\\n\\nWithin that envelope, you get a sequence of file operations.\\nYou MUST include a header to specify the action you are taking.\\nEach operation starts with one of three headers:\\n\\n*** Add File: - create a new file. Every following line is a + line (the initial contents).\\n*** Delete File: - remove an existing file. Nothing follows.\\n*** Update File: - patch an existing file in place (optionally with a rename).\\n\\nExample patch:\\n\\n```\\n*** Begin Patch\\n*** Add File: hello.txt\\n+Hello world\\n*** Update File: src/app.py\\n*** Move to: src/main.py\\n@@ def greet():\\n-print(\\\"Hi\\\")\\n+print(\\\"Hello, world!\\\")\\n*** Delete File: obsolete.txt\\n*** End Patch\\n```\\n\\nIt is important to remember:\\n\\n- You must include a header with your intended action (Add/Delete/Update)\\n- You must prefix new lines with `+` even when creating a new file\\n\", \"name\": \"apply_patch\", \"parameters\": {\"type\": \"object\", \"properties\": {\"patchText\": {\"description\": \"The full patch text that describes all changes to be made\", \"type\": \"string\"}}, \"required\": [\"patchText\"], \"additionalProperties\": false}, \"strict\": false}], \"top_logprobs\": 0, \"top_p\": 1.0, \"truncation\": \"disabled\", \"usage\": {\"input_tokens\": 5585, \"input_tokens_details\": {\"cached_tokens\": 0}, \"output_tokens\": 74, \"output_tokens_details\": {\"reasoning_tokens\": 24}, \"total_tokens\": 5659}, \"user\": null, \"metadata\": {}}, \"sequence_number\": 42}\n\n", + "response_content_type": "text/event-stream; charset=utf-8", + "duration_ms": 1992 +} \ No newline at end of file diff --git a/internal/greyproxy/ui/pages.go b/internal/greyproxy/ui/pages.go index 8cccbb3..53eec6b 100644 --- a/internal/greyproxy/ui/pages.go +++ b/internal/greyproxy/ui/pages.go @@ -208,6 +208,36 @@ var funcMap = template.FuncMap{ } return false }, + // toolIconCategory normalizes tool names across providers (Anthropic PascalCase, + // OpenAI lowercase) and returns a category string for icon rendering. + "toolIconCategory": func(tool string) string { + switch strings.ToLower(tool) { + case "read": + return "read" + case "edit", "apply_patch": + return "edit" + case "write", "notebookedit": + return "write" + case "bash": + return "bash" + case "grep", "glob": + return "search" + case "agent", "task": + return "agent" + case "webfetch", "websearch": + return "web" + case "skill": + return "skill" + case "toolsearch": + return "toolsearch" + case "askuserquestion", "question": + return "question" + case "todowrite", "todoread": + return "todo" + default: + return "generic" + } + }, "hasStepField": func(step any, field string) bool { if m, ok := step.(map[string]any); ok { v, exists := m[field] diff --git a/internal/greyproxy/ui/templates/partials/conversation_detail.html b/internal/greyproxy/ui/templates/partials/conversation_detail.html index f86c838..d8345ee 100644 --- a/internal/greyproxy/ui/templates/partials/conversation_detail.html +++ b/internal/greyproxy/ui/templates/partials/conversation_detail.html @@ -57,32 +57,6 @@

{{end}} - - {{if .Conv.LinkedSubagents}} -
-

Linked Subagents ({{len .Subagents}})

-
- {{range .Subagents}} -
-
-
- - - - {{.TurnCount}} {{pluralize .TurnCount "turn" "turns"}} - {{if hasValue .Model}}{{derefStr .Model}}{{end}} -
-
- {{if hasValue .FirstPrompt}} -

{{truncate (derefStr .FirstPrompt) 120}}

- {{end}} -
- {{end}} -
-
- {{end}} - {{if .Conv.Turns}}
@@ -150,19 +124,29 @@

- {{$tool := index . "tool"}} - {{if eq $tool "Read"}} + {{$cat := toolIconCategory (index . "tool")}} + {{if eq $cat "read"}} - {{else if eq $tool "Edit"}} + {{else if eq $cat "edit"}} - {{else if eq $tool "Write"}} + {{else if eq $cat "write"}} - {{else if eq $tool "Bash"}} + {{else if eq $cat "bash"}} - {{else if or (eq $tool "Grep") (eq $tool "Glob")}} + {{else if eq $cat "search"}} - {{else if eq $tool "Agent"}} + {{else if eq $cat "agent"}} + {{else if eq $cat "web"}} + + {{else if eq $cat "skill"}} + + {{else if eq $cat "question"}} + + {{else if eq $cat "todo"}} + + {{else if eq $cat "toolsearch"}} + {{else}} {{end}} diff --git a/scripts/test-matrix/README.md b/scripts/test-matrix/README.md new file mode 100644 index 0000000..3ca7e51 --- /dev/null +++ b/scripts/test-matrix/README.md @@ -0,0 +1,62 @@ +# Test Matrix Runner + +Runs AI coding agents through 3 standard scenarios while greyproxy captures their HTTP traffic. +Purpose: collect real request/response samples to build wire decoders and client adapters. + +## Prerequisites + +- greyproxy running and intercepting HTTPS traffic +- At least one agent installed and authenticated + +## Usage + +```bash +# Dry-run first to see what will be executed +PREFIX="greywall --" ./run.sh --dry-run all + +# Run a single agent +PREFIX="greywall --" ./run.sh claudecode + +# Run one scenario across all agents +PREFIX="greywall --" ./run.sh --scenario A all + +# Override model +PREFIX="greywall --" ./run.sh --model gpt-4o codex +``` + +`PREFIX="greywall --"` routes each agent's traffic through the proxy. + +## Agents + +| Name | Binary | Status | +|---|---|---| +| `claudecode` | `claude` | verified | +| `opencode` | `opencode` | verified | +| `codex` | `codex` | verified | +| `aider` | `aider` | verified | +| `gemini` | `gemini` | verified | +| `goose` | `goose` | verify `goose run --help` | +| `amp` | `amp` | verify `amp --help` | +| `cursor` | `cursor` | verify `cursor agent --help` | +| `continue` | `cn` | verify `cn --help` | + +## Scenarios + +| | Prompt | Dir contents | What it tests | +|---|---|---|---| +| **A** | "Say hello world" | empty | session ID, wire format, basic SSE | +| **B** | "Read README.md, write SUMMARY.md" | `README.md` | tool call format, tool result embedding | +| **C** | "Two subagents: list TODOs, count LOC" | `src/*.py` | subagent spawning, cross-session linking | + +## After running + +Each run leaves a temp dir at `/tmp/greyproxy-matrix---*`. + +Export captured transactions from the greyproxy DB: + +```bash +sqlite3 ~/.local/share/greyproxy/greyproxy.db \ + "SELECT id, url, substr(request_body,1,300) FROM http_transactions ORDER BY id DESC LIMIT 30" +``` + +Save interesting ones to `internal/greyproxy/dissector/testdata/_.json` for use in dissector tests. diff --git a/scripts/test-matrix/run.sh b/scripts/test-matrix/run.sh new file mode 100755 index 0000000..6e2164f --- /dev/null +++ b/scripts/test-matrix/run.sh @@ -0,0 +1,642 @@ +#!/usr/bin/env bash +# run.sh — AI coding agent test matrix runner for greyproxy dissector research. +# +# Each agent runs 3 scenarios in isolated temp directories so greyproxy can +# capture the HTTP traffic and we can build wire decoders + client adapters. +# +# Usage: +# ./run.sh [OPTIONS] +# +# TARGET: +# all Run every available agent +# claudecode Claude Code (claude CLI by Anthropic) +# opencode OpenCode (opencode CLI by SST) +# codex Codex CLI (codex by OpenAI) +# aider Aider (aider by Paul Gauthier) +# gemini Gemini CLI (gemini by Google) +# goose Goose (goose by Block) +# amp Amp (amp by Sourcegraph) +# cursor Cursor agent (cursor agent CLI) +# continue Continue.dev (cn CLI) +# +# Options: +# --isolate Spin up a dedicated greyproxy on test ports with a +# separate database. Safe to run alongside production. +# (Overrides PREFIX for proxy routing.) +# --greyproxy-bin PATH Path to greyproxy binary (default: auto-detect) +# --dry-run Print commands without executing them +# --scenario A|B|C|all Run only a specific scenario (default: all) +# --model MODEL Override model (where supported) +# --prefix PREFIX greywall prefix when not using --isolate (overrides PREFIX env var) +# +# Environment: +# PREFIX Prepended to every agent invocation (default: empty) +# GREYPROXY_BIN Path to greyproxy binary (alternative to --greyproxy-bin) +# GREYPROXY_DATA_HOME Production data dir (used for CA cert copy in --isolate) +# +# Examples: +# ./run.sh --isolate claudecode +# ./run.sh --isolate --dry-run all +# ./run.sh --isolate --scenario C opencode +# PREFIX="greywall --" ./run.sh claudecode # use production proxy + +set -euo pipefail + +# ─── Config ────────────────────────────────────────────────────────────────── + +PREFIX="${PREFIX:-}" +DRY_RUN=false +SCENARIO="all" +MODEL_OVERRIDE="" +ISOLATE=false +GREYPROXY_BIN="${GREYPROXY_BIN:-}" + +# Isolated instance ports (offset +1000 from production defaults) +TEST_PROXY_HTTP=44051 +TEST_PROXY_SOCKS5=44052 +TEST_PROXY_DNS=44053 +TEST_DASHBOARD=44080 + +# Will be set by setup_isolated_proxy +ISOLATED_DATA_DIR="" +ISOLATED_PID="" +ISOLATED_BIN="" + +# ─── Argument parsing ──────────────────────────────────────────────────────── + +usage() { + sed -n '/^# run.sh/,/^[^#]/{ /^#/p }' "$0" | sed 's/^# \?//' + exit 1 +} + +TARGET="" +while [[ $# -gt 0 ]]; do + case "$1" in + --isolate) ISOLATE=true; shift ;; + --greyproxy-bin) GREYPROXY_BIN="$2"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + --scenario) SCENARIO="$2"; shift 2 ;; + --model) MODEL_OVERRIDE="$2"; shift 2 ;; + --prefix) PREFIX="$2"; shift 2 ;; + --help|-h) usage ;; + all|claudecode|opencode|codex|aider|gemini|goose|amp|cursor|continue) + TARGET="$1"; shift ;; + *) echo "Unknown argument: $1"; usage ;; + esac +done + +if [[ -z "$TARGET" ]]; then + echo "Error: target required" + usage +fi + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +RESET='\033[0m' + +log() { echo -e "${CYAN}[run.sh]${RESET} $*"; } +ok() { echo -e "${GREEN}[ok]${RESET} $*"; } +warn() { echo -e "${YELLOW}[warn]${RESET} $*"; } +err() { echo -e "${RED}[err]${RESET} $*"; } + +run_cmd() { + local label="$1"; shift + echo "" + echo -e "${CYAN}━━━ ${label} ━━━${RESET}" + echo -e "${YELLOW}CMD:${RESET} $*" + if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}(dry-run — skipping)${RESET}" + return + fi + "$@" || { err "Command exited with code $?"; return 1; } +} + +# ─── Isolated greyproxy setup ──────────────────────────────────────────────── + +find_greyproxy_bin() { + if [[ -n "$GREYPROXY_BIN" ]]; then + echo "$GREYPROXY_BIN" + return + fi + # Common locations + local candidates=( + "$(dirname "$0")/../../greyproxy" + "/tmp/greyproxy-extract-"*/greyproxy + "$HOME/.local/bin/greyproxy" + "$(command -v greyproxy 2>/dev/null || true)" + ) + for c in "${candidates[@]}"; do + # Expand globs + for f in $c; do + if [[ -x "$f" ]]; then + echo "$f" + return + fi + done + done + echo "" +} + +find_prod_data_dir() { + if [[ -n "${GREYPROXY_DATA_HOME:-}" ]]; then + echo "$GREYPROXY_DATA_HOME" + return + fi + if [[ -n "${XDG_DATA_HOME:-}" ]]; then + echo "$XDG_DATA_HOME/greyproxy" + return + fi + echo "$HOME/.local/share/greyproxy" +} + +write_isolated_config() { + local cfg_file="$1" + local data_dir="$2" + cat > "$cfg_file" < "$ISOLATED_DATA_DIR/greyproxy.log" 2>&1 & + ISOLATED_PID=$! + log "isolated greyproxy PID: $ISOLATED_PID" + + # Wait for dashboard to be ready (up to 15s) + local deadline=$(( $(date +%s) + 15 )) + while [[ $(date +%s) -lt $deadline ]]; do + if curl -sf "http://localhost:${TEST_DASHBOARD}/api/health" >/dev/null 2>&1 || + curl -sf "http://localhost:${TEST_DASHBOARD}/" >/dev/null 2>&1; then + ok "isolated greyproxy is ready" + return + fi + sleep 0.5 + done + err "isolated greyproxy did not start within 15s — check $ISOLATED_DATA_DIR/greyproxy.log" + cat "$ISOLATED_DATA_DIR/greyproxy.log" | tail -20 >&2 + exit 1 +} + +teardown_isolated_proxy() { + if [[ -n "$ISOLATED_PID" ]] && kill -0 "$ISOLATED_PID" 2>/dev/null; then + log "stopping isolated greyproxy (PID $ISOLATED_PID)" + kill "$ISOLATED_PID" 2>/dev/null || true + wait "$ISOLATED_PID" 2>/dev/null || true + fi + if [[ -n "$ISOLATED_DATA_DIR" ]]; then + echo "" + ok "Isolated greyproxy stopped." + echo -e " DB for analysis: ${GREEN}${ISOLATED_DATA_DIR}/greyproxy.db${RESET}" + echo -e " Dashboard log: ${ISOLATED_DATA_DIR}/greyproxy.log" + echo "" + echo "Open in dashboard (re-run isolated instance pointing at this DB):" + echo " GREYPROXY_DATA_HOME=$ISOLATED_DATA_DIR $ISOLATED_BIN serve -C $ISOLATED_DATA_DIR/greyproxy.yml" + echo "" + echo "Or query directly:" + echo " sqlite3 $ISOLATED_DATA_DIR/greyproxy.db \\" + echo " \"SELECT id, url, substr(request_body,1,200) FROM http_transactions ORDER BY id\"" + echo "" + echo "Debug logs for api.openai.com (copy to clipboard):" + echo " grep openai $ISOLATED_DATA_DIR/greyproxy.log | wl-copy" + fi +} + +# ─── Scenario setup ────────────────────────────────────────────────────────── + +make_tmpdir() { + local agent="$1" + local scenario="$2" + local dir + dir="$(mktemp -d "/tmp/greyproxy-matrix-${agent}-${scenario}-XXXXXX")" + + if [[ "$scenario" == "B" || "$scenario" == "C" ]]; then + cat > "$dir/README.md" <<'EOF' +# greyproxy test project + +A minimal project used to test AI coding agent behavior through the greyproxy. + +## Overview + +This project provides a proxy layer for capturing and dissecting LLM API traffic. +The main components are the dissector pipeline and the conversation assembler. + +## Status + +Work in progress. See docs/context-dissectors/ for architecture notes. +EOF + fi + + if [[ "$scenario" == "C" ]]; then + mkdir -p "$dir/src" + cat > "$dir/src/main.py" <<'EOF' +# TODO: add error handling for network timeouts +# TODO: support streaming responses + +def process_request(data): + """Process an incoming API request.""" + return {"status": "ok", "data": data} + +def main(): + # TODO: read config from file + print("starting server") + result = process_request({"input": "hello"}) + print(result) + +if __name__ == "__main__": + main() +EOF + cat > "$dir/src/utils.py" <<'EOF' +# TODO: replace with a proper logging library + +def format_json(data): + """Format data as indented JSON string.""" + import json + return json.dumps(data, indent=2) + +def count_tokens(text): + # TODO: implement proper tokenizer + return len(text.split()) + +class Config: + """Application configuration.""" + # TODO: load from environment variables + DEBUG = False + MAX_RETRIES = 3 +EOF + cat > "$dir/src/api.py" <<'EOF' +from utils import format_json + +def handle_get(path): + return format_json({"path": path, "method": "GET"}) + +def handle_post(path, body): + # TODO: validate body schema + return format_json({"path": path, "method": "POST", "body": body}) +EOF + fi + + echo "$dir" +} + +# ─── Scenario prompts ──────────────────────────────────────────────────────── + +PROMPT_A="Say hello world, nothing else. One sentence only." + +PROMPT_B="Read the file README.md. Then write a single sentence summary of it to a new file called SUMMARY.md." + +PROMPT_C="You have two tasks. First, use a subagent (or agent tool) to find all TODO comments in the src/ directory and list them. Second, use another subagent to count the total number of lines of Python code in src/. Once both subagents finish, write a combined report to REPORT.md. If your tools do not support subagents, do both tasks yourself and still write REPORT.md." + +# ─── Agent runners ─────────────────────────────────────────────────────────── + +run_claudecode() { + local scenario="$1" prompt="$2" + local tmpdir; tmpdir="$(make_tmpdir claudecode "$scenario")" + log "claudecode scenario $scenario → $tmpdir" + local model_flag="" + [[ -n "$MODEL_OVERRIDE" ]] && model_flag="--model $MODEL_OVERRIDE" + # shellcheck disable=SC2086 + run_cmd "claudecode/$scenario" \ + bash -c "cd $(printf '%q' "$tmpdir") && $PREFIX claude \ + --dangerously-skip-permissions \ + --no-session-persistence \ + --output-format text \ + $model_flag \ + -p $(printf '%q' "$prompt")" + echo " tmpdir: $tmpdir" +} + +run_opencode() { + local scenario="$1" prompt="$2" + local tmpdir; tmpdir="$(make_tmpdir opencode "$scenario")" + log "opencode scenario $scenario → $tmpdir" + local model_flag="" + [[ -n "$MODEL_OVERRIDE" ]] && model_flag="--model $MODEL_OVERRIDE" + # shellcheck disable=SC2086 + run_cmd "opencode/$scenario" \ + bash -c "cd $(printf '%q' "$tmpdir") && $PREFIX opencode run \ + $model_flag \ + $(printf '%q' "$prompt")" + echo " tmpdir: $tmpdir" +} + +run_codex() { + local scenario="$1" prompt="$2" + local tmpdir; tmpdir="$(make_tmpdir codex "$scenario")" + log "codex scenario $scenario → $tmpdir" + local model_flag="" + [[ -n "$MODEL_OVERRIDE" ]] && model_flag="--model $MODEL_OVERRIDE" + # shellcheck disable=SC2086 + run_cmd "codex/$scenario" \ + bash -c "$PREFIX codex \ + --cd $(printf '%q' "$tmpdir") \ + --ask-for-approval never \ + --sandbox workspace-write \ + $model_flag \ + exec --ephemeral \ + $(printf '%q' "$prompt")" + echo " tmpdir: $tmpdir" +} + +run_aider() { + local scenario="$1" prompt="$2" + local tmpdir; tmpdir="$(make_tmpdir aider "$scenario")" + log "aider scenario $scenario → $tmpdir" + local model_flag="" + [[ -n "$MODEL_OVERRIDE" ]] && model_flag="--model $MODEL_OVERRIDE" + local files=() + [[ "$scenario" == "B" ]] && files=("README.md" "SUMMARY.md") + [[ "$scenario" == "C" ]] && files=("src/main.py" "src/utils.py" "src/api.py" "REPORT.md") + # shellcheck disable=SC2086 + run_cmd "aider/$scenario" \ + bash -c "cd $(printf '%q' "$tmpdir") && $PREFIX aider \ + --message $(printf '%q' "$prompt") \ + --yes-always \ + --no-git \ + --no-auto-commits \ + --no-stream \ + $model_flag \ + ${files[*]:-}" + echo " tmpdir: $tmpdir" +} + +run_gemini() { + local scenario="$1" prompt="$2" + local tmpdir; tmpdir="$(make_tmpdir gemini "$scenario")" + log "gemini scenario $scenario → $tmpdir" + local model_flag="" + [[ -n "$MODEL_OVERRIDE" ]] && model_flag="-m $MODEL_OVERRIDE" + # shellcheck disable=SC2086 + run_cmd "gemini/$scenario" \ + bash -c "cd $(printf '%q' "$tmpdir") && $PREFIX gemini \ + $model_flag \ + -y \ + -p $(printf '%q' "$prompt")" + echo " tmpdir: $tmpdir" +} + +run_goose() { + local scenario="$1" prompt="$2" + local tmpdir; tmpdir="$(make_tmpdir goose "$scenario")" + log "goose scenario $scenario → $tmpdir" + local model_flag="" + [[ -n "$MODEL_OVERRIDE" ]] && model_flag="--model $MODEL_OVERRIDE" + # shellcheck disable=SC2086 + run_cmd "goose/$scenario" \ + bash -c "cd $(printf '%q' "$tmpdir") && GOOSE_MODE=auto $PREFIX goose run \ + --with-builtin developer \ + --no-session \ + $model_flag \ + -t $(printf '%q' "$prompt")" + echo " tmpdir: $tmpdir" +} + +run_amp() { + local scenario="$1" prompt="$2" + local tmpdir; tmpdir="$(make_tmpdir amp "$scenario")" + log "amp scenario $scenario → $tmpdir" + # shellcheck disable=SC2086 + run_cmd "amp/$scenario" \ + bash -c "cd $(printf '%q' "$tmpdir") && $PREFIX amp \ + --dangerously-allow-all \ + -x $(printf '%q' "$prompt")" + echo " tmpdir: $tmpdir" +} + +run_cursor() { + local scenario="$1" prompt="$2" + local tmpdir; tmpdir="$(make_tmpdir cursor "$scenario")" + log "cursor scenario $scenario → $tmpdir" + local model_flag="" + [[ -n "$MODEL_OVERRIDE" ]] && model_flag="--model $MODEL_OVERRIDE" + # shellcheck disable=SC2086 + run_cmd "cursor/$scenario" \ + bash -c "$PREFIX cursor agent \ + --print \ + --trust \ + --workspace $(printf '%q' "$tmpdir") \ + $model_flag \ + $(printf '%q' "$prompt")" + echo " tmpdir: $tmpdir" + echo " NOTE: requires Cursor installed and authenticated" +} + +run_continue() { + local scenario="$1" prompt="$2" + local tmpdir; tmpdir="$(make_tmpdir continue "$scenario")" + log "continue scenario $scenario → $tmpdir" + local model_flag="" + [[ -n "$MODEL_OVERRIDE" ]] && model_flag="--model $MODEL_OVERRIDE" + # shellcheck disable=SC2086 + run_cmd "continue/$scenario" \ + bash -c "cd $(printf '%q' "$tmpdir") && $PREFIX cn \ + --auto \ + $model_flag \ + -p $(printf '%q' "$prompt")" + echo " tmpdir: $tmpdir" +} + +# ─── Dispatch ──────────────────────────────────────────────────────────────── + +AGENTS_ALL=(claudecode opencode codex aider gemini goose amp cursor continue) + +declare -A SCENARIOS +SCENARIOS[A]="$PROMPT_A" +SCENARIOS[B]="$PROMPT_B" +SCENARIOS[C]="$PROMPT_C" + +agent_cmd() { + case "$1" in + claudecode) echo "claude" ;; + continue) echo "cn" ;; + *) echo "$1" ;; + esac +} + +run_agent() { + local agent="$1" + echo "" + echo -e "${GREEN}════════════════════════════════════${RESET}" + echo -e "${GREEN} Agent: ${agent}${RESET}" + echo -e "${GREEN}════════════════════════════════════${RESET}" + + local bin; bin="$(agent_cmd "$agent")" + if ! command -v "$bin" &>/dev/null 2>&1; then + warn "$bin not found in PATH — skipping (dry-run will still print)" + [[ "$DRY_RUN" != "true" ]] && return + fi + + local letters + if [[ "$SCENARIO" == "all" ]]; then + letters=(A B C) + else + letters=("$SCENARIO") + fi + + for letter in "${letters[@]}"; do + local prompt="${SCENARIOS[$letter]}" + "run_${agent}" "$letter" "$prompt" + done +} + +# ─── Main ──────────────────────────────────────────────────────────────────── + +# If --isolate, start a dedicated greyproxy and build the PREFIX +if [[ "$ISOLATE" == "true" ]]; then + setup_isolated_proxy + # Override PREFIX to point greywall at the isolated instance + PREFIX="greywall \ + --profile relaxed \ + --no-credential-protection \ + --proxy socks5://localhost:${TEST_PROXY_SOCKS5} \ + --http-proxy http://localhost:${TEST_PROXY_HTTP} \ + --dns localhost:${TEST_PROXY_DNS} \ + --" + # PREFIX="env ALL_PROXY=socks5h://localhost:${TEST_PROXY_SOCKS5} HTTP_PROXY=http://localhost:${TEST_PROXY_HTTP}" + # Tear down on exit (normal, error, or Ctrl-C) + trap teardown_isolated_proxy EXIT +fi + +echo "" +echo -e "${CYAN}greyproxy dissector test matrix runner${RESET}" +echo -e " target: ${TARGET}" +echo -e " scenario: ${SCENARIO}" +echo -e " isolate: ${ISOLATE}" +echo -e " dry-run: ${DRY_RUN}" +if [[ "$ISOLATE" == "true" && -n "$ISOLATED_DATA_DIR" ]]; then + echo -e " test DB: ${ISOLATED_DATA_DIR}/greyproxy.db" + echo -e " dashboard: http://localhost:${TEST_DASHBOARD}" +fi +if [[ -n "$MODEL_OVERRIDE" ]]; then + echo -e " model: ${MODEL_OVERRIDE}" +fi +echo "" + +if [[ "$TARGET" == "all" ]]; then + for agent in "${AGENTS_ALL[@]}"; do + run_agent "$agent" + done +else + run_agent "$TARGET" +fi + +echo "" +ok "Done."