test-driven-development

---
name: test-driven-development
description: "TDD: enforce RED-GREEN-REFACTOR, tests before code."
version: 1.1.0
author: Hermes Agent (adapted from obra/superpowers)
license: MIT
platforms: [linux, macos, windows]
metadata:
  hermes:
    tags: [testing, tdd, development, quality, red-green-refactor]
    related_skills: [systematic-debugging, writing-plans, subagent-driven-development]
---

# Test-Driven Development (TDD)

## Overview

Write the test first. Watch it fail. Write minimal code to pass.

**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing.

**Violating the letter of the rules is violating the spirit of the rules.**

## When to Use

**Always:**
- New features
- Bug fixes
- Refactoring
- Behavior changes

**Exceptions (ask the user first):**
- Throwaway prototypes
- Generated code
- Configuration files

Thinking "skip TDD just this once"? Stop. That's rationalization.

## The Iron Law

```
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
```

Write code before the test? Delete it. Start over.

**No exceptions:**
- Don't keep it as "reference"
- Don't "adapt" it while writing tests
- Don't look at it
- Delete means delete

Implement fresh from tests. Period.

## Red-Green-Refactor Cycle

### RED — Write Failing Test

Write one minimal test showing what should happen.

**Good test:**
```python
def test_retries_failed_operations_3_times():
    attempts = 0
    def operation():
        nonlocal attempts
        attempts += 1
        if attempts < 3:
            raise Exception('fail')
        return 'success'

    result = retry_operation(operation)

    assert result == 'success'
    assert attempts == 3
```
Clear name, tests real behavior, one thing.

**Bad test:**
```python
def test_retry_works():
    mock = MagicMock()
    mock.side_effect = [Exception(), Exception(), 'success']
    result = retry_operation(mock)
    assert result == 'success'  # What about retry count? Timing?
```
Vague name, tests mock not real code.

**Requirements:**
- One behavior per test
- Clear descriptive name ("and" in name? Split it)
- Real code, not mocks (unless truly unavoidable)
- Name describes behavior, not implementation

### Verify RED — Watch It Fail

**MANDATORY. Never skip.**

```bash
# Use terminal tool to run the specific test
pytest tests/test_feature.py::test_specific_behavior -v
```

Confirm:
- Test fails (not errors from typos)
- Failure message is expected
- Fails because the feature is missing

**Test passes immediately?** You're testing existing behavior. Fix the test.

**Test errors?** Fix the error, re-run until it fails correctly.

### GREEN — Minimal Code

Write the simplest code to pass the test. Nothing more.

**Good:**
```python
def add(a, b):
    return a + b  # Nothing extra
```

**Bad:**
```python
def add(a, b):
    result = a + b
    logging.info(f"Adding {a} + {b} = {result}")  # Extra!
    return result
```

Don't add features, refactor other code, or "improve" beyond the test.

**Cheating is OK in GREEN:**
- Hardcode return values
- Copy-paste
- Duplicate code
- Skip edge cases

We'll fix it in REFACTOR.

### Verify GREEN — Watch It Pass

**MANDATORY.**

```bash
# Run the specific test
pytest tests/test_feature.py::test_specific_behavior -v

# Then run ALL tests to check for regressions
pytest tests/ -q
```

Confirm:
- Test passes
- Other tests still pass
- Output pristine (no errors, warnings)

**Test fails?** Fix the code, not the test.

**Other tests fail?** Fix regressions now.

### REFACTOR — Clean Up

After green only:
- Remove duplication
- Improve names
- Extract helpers
- Simplify expressions

Keep tests green throughout. Don't add behavior.

**If tests fail during refactor:** Undo immediately. Take smaller steps.

### Repeat

Next failing test for next behavior. One cycle at a time.

## Why Order Matters

**"I'll write tests after to verify it works"**

Tests written after code pass immediately. Passing immediately proves nothing:
- Might test the wrong thing
- Might test implementation, not behavior
- Might miss edge cases you forgot
- You never saw it catch the bug

Test-first forces you to see the test fail, proving it actually tests something.

**"I already manually tested all the edge cases"**

Manual testing is ad-hoc. You think you tested everything but:
- No record of what you tested
- Can't re-run when code changes
- Easy to forget cases under pressure
- "It worked when I tried it" ≠ comprehensive

Automated tests are systematic. They run the same way every time.

**"Deleting X hours of work is wasteful"**

Sunk cost fallacy. The time is already gone. Your choice now:
- Delete and rewrite with TDD (high confidence)
- Keep it and add tests after (low confidence, likely bugs)

The "waste" is keeping code you can't trust.

**"TDD is dogmatic, being pragmatic means adapting"**

TDD IS pragmatic:
- Finds bugs before commit (faster than debugging after)
- Prevents regressions (tests catch breaks immediately)
- Documents behavior (tests show how to use code)
- Enables refactoring (change freely, tests catch breaks)

"Pragmatic" shortcuts = debugging in production = slower.

**"Tests after achieve the same goals — it's spirit not ritual"**

No. Tests-after answer "What does this do?" Tests-first answer "What should this do?"

Tests-after are biased by your implementation. You test what you built, not what's required. Tests-first force edge case discovery before implementing.

## Common Rationalizations

| Excuse | Reality |
|--------|---------|
| "Too simple to test" | Simple code breaks. Test takes 30 seconds. |
| "I'll test after" | Tests passing immediately prove nothing. |
| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" |
| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. |
| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. |
| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. |
| "Need to explore first" | Fine. Throw away exploration, start with TDD. |
| "Test hard = design unclear" | Listen to the test. Hard to test = hard to use. |
| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. |
| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. |
| "Existing code has no tests" | You're improving it. Add tests for the code you touch. |

## Red Flags — STOP and Start Over

If you catch yourself doing any of these, delete the code and restart with TDD:

- Code before test
- Test after implementation
- Test passes immediately on first run
- Can't explain why test failed
- Tests added "later"
- Rationalizing "just this once"
- "I already manually tested it"
- "Tests after achieve the same purpose"
- "Keep as reference" or "adapt existing code"
- "Already spent X hours, deleting is wasteful"
- "TDD is dogmatic, I'm being pragmatic"
- "This is different because..."

**All of these mean: Delete code. Start over with TDD.**

## Verification Checklist

Before marking work complete:

- [ ] Every new function/method has a test
- [ ] Watched each test fail before implementing
- [ ] Each test failed for expected reason (feature missing, not typo)
- [ ] Wrote minimal code to pass each test
- [ ] All tests pass
- [ ] Output pristine (no errors, warnings)
- [ ] Tests use real code (mocks only if unavoidable)
- [ ] Edge cases and errors covered

Can't check all boxes? You skipped TDD. Start over.

## When Stuck

| Problem | Solution |
|---------|----------|
| Don't know how to test | Write the wished-for API. Write the assertion first. Ask the user. |
| Test too complicated | Design too complicated. Simplify the interface. |
| Must mock everything | Code too coupled. Use dependency injection. |
| Test setup huge | Extract helpers. Still complex? Simplify the design. |

## Hermes Agent Integration

### Running Tests

Use the `terminal` tool to run tests at each step:

```python
# RED — verify failure
terminal("pytest tests/test_feature.py::test_name -v")

# GREEN — verify pass
terminal("pytest tests/test_feature.py::test_name -v")

# Full suite — verify no regressions
terminal("pytest tests/ -q")
```

### CLI Entry Points

For new `python -m package.cli.command` style entry points, keep the TDD loop boring and side-effect free:

1. Write a test that imports `main` from the missing CLI module and calls `main([])`.
2. Capture stdout/stderr with the project test framework (`capsys` in pytest) or inject a `stdout` file-like object if supported.
3. RED should usually be an import failure for the missing CLI module/package, not a vague end-to-end shell failure.
4. Implement `main(argv: Sequence[str] | None = None, *, stdout: TextIO | None = None) -> int` so tests can call it without spawning a subprocess.
5. Add the `if __name__ == "__main__": raise SystemExit(main())` guard only after the callable path is green.
6. Verify both the unit test and the real module invocation, e.g. `python -m package.cli.command`, before committing.

This catches command wiring while avoiding accidental network calls, publishing actions, or other theatrical nonsense masquerading as a CLI test.

### With delegate_task

When dispatching subagents for implementation, enforce TDD in the goal:

```python
delegate_task(
    goal="Implement [feature] using strict TDD",
    context="""
    Follow test-driven-development skill:
    1. Write failing test FIRST
    2. Run test to verify it fails
    3. Write minimal code to pass
    4. Run test to verify it passes
    5. Refactor if needed
    6. Commit

    Project test command: pytest tests/ -q
    Project structure: [describe relevant files]
    """,
    toolsets=['terminal', 'file']
)
```

### With systematic-debugging

Bug found? Write failing test reproducing it. Follow TDD cycle. The test proves the fix and prevents regression.

Never fix bugs without a test.

## Demo / Fixture Features

When adding a deterministic demo fixture for later integration wiring, still use RED-GREEN-REFACTOR. Write the wished-for import/API in the test first, verify the missing module/API failure, then implement the smallest fixture that proves the real workflow path. Assert stable IDs/order, bucket counts/classifications, and safety invariants such as review text not implying publication. See `references/deterministic-demo-fixtures.md` for the concise pattern.

## Side-Effect-Free Integration Boundaries

When building toward an external integration (Telegram, email, publishing APIs, render farms, payment flows, etc.), add a tested preparation boundary before any real I/O:

1. Write the wished-for pure function in the test first, e.g. `prepare_review_delivery(text) -> DeliveryPlan`.
2. RED should be a missing module/API or missing validation failure, not a real network/send failure.
3. The GREEN implementation should return a typed/dataclass plan object with explicit metadata: destination/topic, delivery type, human-approval requirement, and `side_effects=()`.
4. Validate safety constraints at the boundary: non-empty content, platform size limits, no forbidden action language, no implicit approval, no credentials or generated media unless explicitly expected.
5. Add a demo CLI only after the pure boundary is green. The CLI should print metadata first, then payload, and still perform no external I/O.
6. Verify both the unit tests and a real `python -m package.cli.demo_*` invocation before committing.

This pattern lets later sending/rendering/publishing adapters consume a boring, audited object instead of smuggling side effects into formatting code. Boring is how we avoid building a content catapult with a moustache.

### Media / Rendering Pipeline Boundaries

When building toward image/video generation or renderer integration, add deterministic planning artifacts before any GPU/API work:

1. Write tests for the wished-for pure API first, e.g. `create_shot_plan(script)`, `build_render_manifest(plan)`, `build_local_preview_plan(manifest)`, or `build_ffmpeg_concat_plan(plan, frames, output_path)`.
2. RED should be a missing module/API or missing validation behavior — never a failed ComfyUI/Wan/Qwen/API invocation.
3. Keep each stage inspectable and side-effect-free until the explicit adapter layer: `ScriptDraft -> ShotPlan -> RenderManifest -> PreviewPlan/PreviewArtifact -> renderer adapter`.
4. Assert stable IDs/order, scene counts, timing continuity, vertical settings (`1080x1920`, `30fps` when building Shorts/Reels/TikTok), reduced preview settings (`540x960`, `24fps` when building fast local timing previews), and placeholder/runtime paths before generating media.
5. Add guardrail tests that reject static slideshows, baked-in/generated text inside assets, non-renderer-owned captions/labels, unsafe render settings, missing fields, bad timings, non-PNG frame paths, non-MP4 output paths, and mismatched frame counts.
6. Split artifact-writing/rendering into explicit boundaries after the pure plans are green: frame writer returns `side_effects=("write_png",)`, concat planning remains side-effect-free, and the FFmpeg renderer returns `side_effects=("write_concat", "call_ffmpeg", "write_mp4")` plus `external_calls=("ffmpeg",)`.
7. Only after the manifest/preview boundary is green should a later adapter call local GPU tools or external services. Planning first; GPU roulette later.

This prevents wasting render time on unclear direction and keeps expensive media generation behind a small, audited boundary.

### Approval-First Media/Social Pipelines

When a feature crosses from content planning toward media/social delivery, prefer many small RED-GREEN-README-COMMIT slices instead of one large integration jump:

1. typed artifact/model (`SocialMediaDraft`, `RenderManifest`, etc.)
2. renderer/formatter for human review text
3. side-effect-free delivery/preparation boundary
4. demo CLI that prints metadata first, then payload

For each slice, write the missing import/API test first, verify the expected import/API failure, implement only that slice, run the focused test, run the full suite, update docs, and commit. Keep invariant tests explicit: `requires_human_approval=True`, `side_effects=()`, no external calls, no credential access, no file writes unless that adapter is the explicit feature, and no accidental action language such as `publish`/`post` in review/delivery text.

This staged approach keeps approval semantics auditable and prevents a later renderer, Telegram sender, or platform adapter from being smuggled into formatting code.

## Testing Anti-Patterns

- **Testing mock behavior instead of real behavior** — mocks should verify interactions, not replace the system under test
- **Testing implementation details** — test behavior/results, not internal method calls
- **Happy path only** — always test edge cases, errors, and boundaries
- **Brittle tests** — tests should verify behavior, not structure; refactoring shouldn't break them
- **Substring sentinels that collide with generated IDs** — when asserting that amounts or fixture values are excluded from a result, assert the full formatted token (e.g. `999.00`) or a typed field, not a short substring like `999` that can appear inside UUIDs/hash-like IDs.
- **Domain classes named `Test*` trigger pytest collection warnings** — if a production dataclass/model must be named like `TestBatch`, set `__test__ = False` on the class or choose a non-`Test` prefix. Keep the fix in production code when the name is part of the domain language, and verify the warning is gone before committing.
- **Defaulting with `x or default` hides invalid explicit inputs** — when a factory/test helper accepts optional overrides, use `x if x is not None else default` so tests can pass intentionally blank strings (`""`) or zero values and watch validation reject them. Otherwise invalid-input tests may silently receive defaults and give false confidence.

## Final Rule

```
Production code → test exists and failed first
Otherwise → not TDD
```

No exceptions without the user's explicit permission.


Explanation:
**Inspecting repository**

I need to take a proper look at the repository. It seems like there’s something specific I’m supposed to check. I want to ensure that I'm not missing any crucial details or important elements. Digging into the code and structure will help clarify what’s going on, and I’m curious about what I might find there. Let’s make sure I understand how it all works together! Alright, time to inspect that repo thoroughly.

todo

Image Provider Audit: config/logs/metadata prüfen

Image Generation Guidelines und Policy ergänzen

Caption Safe-Zone Tests zuerst schreiben und Fix implementieren

Preview v2 + QA Artefakte rendern und verifizieren

Artefakte per Telegram senden/verifizieren und Abschlussbericht


from hermes_tools import terminal, search_files
print('PWD/STATUS')
print(terminal('pwd && git status --short && git branch --show-current', timeout=30)['output'])
print('top files')
print(search_files('*', target='files', path='.', limit=80))
print('config relevant')
print(terminal('python - <<\'PY\'\nimport yaml, os, json\nfrom pathlib import Path\np=Path.home()/\'.hermes/config.yaml\'\nprint(p)\ncfg=yaml.safe_load(p.read_text()) if p.exists() else {}\nfor k in [\'image_gen\',\'model\',\'providers\']:\n print(k, json.dumps(cfg.get(k), indent=2, default=str)[:2000])\nPY', timeout=30)['output'])


Explanation:
**Exploring image generation tools**

I need to set up a working directory and check the files related to the image generation registry and logs. I might want to use the image_generate tool for testing capabilities, but I noticed the backend is currently configured for gpt-image-2-medium. I’m considering whether I can change the config to high. It seems that testing capabilities through the tool might use the current config, but I can't adjust the model with each call. Maybe I should look into the provider code directly to see what's possible.


{"total_count": 0}

[Subdirectory context discovered: .hermes/hermes-agent/AGENTS.md]
# Hermes Agent - Development Guide

Instructions for AI coding assistants and developers working on the hermes-agent codebase.

## Development Environment

```bash
# Prefer .venv; fall back to venv if that's what your checkout has.
source .venv/bin/activate   # or: source venv/bin/activate
```

`scripts/run_tests.sh` probes `.venv` first, then `venv`, then
`$HOME/.hermes/hermes-agent/venv` (for worktrees that share a venv with the
main checkout).

## Project Structure

File counts shift constantly — don't treat the tree below as exhaustive.
The canonical source is the filesystem. The notes call out the load-bearing
entry points you'll actually edit.

```
hermes-agent/
├── run_agent.py          # AIAgent class — core conversation loop (~12k LOC)
├── model_tools.py        # Tool orchestration, discover_builtin_tools(), handle_function_call()
├── toolsets.py           # Toolset definitions, _HERMES_CORE_TOOLS list
├── cli.py                # HermesCLI class — interactive CLI orchestrator (~11k LOC)
├── hermes_state.py       # SessionDB — SQLite session store (FTS5 search)
├── hermes_constants.py   # get_hermes_home(), display_hermes_home() — profile-aware paths
├── hermes_logging.py     # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
├── batch_runner.py       # Parallel batch processing
├── agent/                # Agent internals (provider adapters, memory, caching, compression, etc.)
├── hermes_cli/           # CLI subcommands, setup wizard, plugins loader, skin engine
├── tools/                # Tool implementations — auto-discovered via tools/registry.py
│   └── environments/     # Terminal backends (local, docker, ssh, modal, daytona, singularity)
├── gateway/              # Messaging gateway — run.py + session.py + platforms/
│   ├── platforms/        # Adapter per platform (telegram, discord, slack, whatsapp,
│   │                     #   homeassistant, signal, matrix, mattermost, email, sms,
│   │                     #   dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
│   │                     #   yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
│   └── builtin_hooks/    # Extension point for always-registered gateway hooks (none shipped)
├── plugins/              # Plugin system (see "Plugins" section below)
│   ├── memory/           # Memory-provider plugins (honcho, mem0, supermemory, ...)
│   ├── context_engine/   # Context-engine plugins
│   ├── model-providers/  # Inference backend plugins (openrouter, anthropic, gmi, ...)
│   ├── kanban/           # Multi-agent board dispatcher + worker plugin
│   ├── hermes-achievements/  # Gamified achievement tracking
│   ├── observability/    # Metrics / traces / logs plugin
│   ├── image_gen/        # Image-generation providers
│   └── <others>/         # disk-cleanup, example-dashboard, google_meet, platforms,
│                         #   spotify, strike-freedom-cockpit, ...
├── optional-skills/      # Heavier/niche skills shipped but NOT active by default
├── skills/               # Built-in skills bundled with the repo
├── ui-tui/               # Ink (React) terminal UI — `hermes --tui`
│   └── src/              # entry.tsx, app.tsx, gatewayClient.ts + app/components/hooks/lib
├── tui_gateway/          # Python JSON-RPC backend for the TUI
├── acp_adapter/          # ACP server (VS Code / Zed / JetBrains integration)
├── cron/                 # Scheduler — jobs.py, scheduler.py
├── scripts/              # run_tests.sh, release.py, auxiliary scripts
├── website/              # Docusaurus docs site
└── tests/                # Pytest suite (~17k tests across ~900 files as of May 2026)
```

**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
**Logs:** `~/.hermes/logs/` — `agent.log` (INFO+), `errors.log` (WARNING+),
`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
Browse with `hermes logs [--follow] [--level ...] [--session ...]`.

## File Dependency Chain

```
tools/registry.py  (no deps — imported by all tool files)
       ↑
tools/*.py  (each calls registry.register() at import time)
       ↑
model_tools.py  (imports tools/registry + triggers tool discovery)
       ↑
run_agent.py, cli.py, batch_runner.py, environments/
```

---

## AIAgent Class (run_agent.py)

The real `AIAgent.__init__` takes ~60 parameters (credentials, routing, callbacks,
session context, budget, credential pool, etc.). The signature below is the
minimum subset you'll usually touch — read `run_agent.py` for the full list.

```python
class AIAgent:
    def __init__(self,
        base_url: str = None,
        api_key: str = None,
        provider: str = None,
        api_mode: str = None,              # "chat_completions" | "codex_responses" | ...
        model: str = "",                   # empty → resolved from config/provider later
        max_iterations: int = 90,          # tool-calling iterations (shared with subagents)
        enabled_toolsets: list = None,
        disabled_toolsets: list = None,
        quiet_mode: bool = False,
        save_trajectories: bool = False,
        platform: str = None,              # "cli", "telegram", etc.
        session_id: str = None,
        skip_context_files: bool = False,
        skip_memory: bool = False,
        credential_pool=None,
        # ... plus callbacks, thread/user/chat IDs, iteration_budget, fallback_model,
        # checkpoints config, prefill_messages, service_tier, reasoning_config, etc.
    ): ...

    def chat(self, message: str) -> str:
        """Simple interface — returns final response string."""

    def run_conversation(self, user_message: str, system_message: str = None,
                         conversation_history: list = None, task_id: str = None) -> dict:
        """Full interface — returns dict with final_response + messages."""
```

### Agent Loop

The core loop is inside `run_conversation()` — entirely synchronous, with
interrupt checks, budget tracking, and a one-turn grace call:

```python
while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \
        or self._budget_grace_call:
    if self._interrupt_requested: break
    response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
    if response.tool_calls:
        for tool_call in response.tool_calls:
            result = handle_function_call(tool_call.name, tool_call.args, task_id)
            messages.append(tool_result_message(result))
        api_call_count += 1
    else:
        return response.content
```

Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`.
Reasoning content is stored in `assistant_msg["reasoning"]`.

---

## CLI Architecture (cli.py)

- **Rich** for banner/panels, **prompt_toolkit** for input with autocomplete
- **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results
- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML
- **Skin engine** (`hermes_cli/skin_engine.py`) — data-driven CLI theming; initialized from `display.skin` config key at startup; skins customize banner colors, spinner faces/verbs/wings, tool prefix, response box, branding text
- `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry
- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching

### Slash Command Registry (`hermes_cli/commands.py`)

All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandDef` objects. Every downstream consumer derives from this registry automatically:

- **CLI** — `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name
- **Gateway** — `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch
- **Gateway help** — `gateway_help_lines()` generat

[...truncated AGENTS.md: 50,982 chars total]


     1|"""Static active-word caption segment logic.
     2|
     3|Caption rule for TrueTraceShorts previews/finals:
     4|- A subtitle phrase segment stays fixed on screen.
     5|- At least three words are visible per default segment whenever possible.
     6|- Only the active spoken word changes highlight.
     7|- The highlight moves left-to-right until the next phrase segment replaces it.
     8|"""
     9|
    10|from __future__ import annotations
    11|
    12|from dataclasses import dataclass
    13|from typing import Iterable, Sequence
    14|
    15|
    16|@dataclass(frozen=True)
    17|class WordCue:
    18|    start: float
    19|    end: float
    20|    word: str
    21|    sentence: str = ""
    22|
    23|
    24|@dataclass(frozen=True)
    25|class StaticCaptionSegment:
    26|    start: float
    27|    end: float
    28|    word_indices: tuple[int, ...]
    29|    words: tuple[str, ...]
    30|    sentence: str = ""
    31|
    32|    @property
    33|    def text(self) -> str:
    34|        return " ".join(self.words)
    35|
    36|
    37|def build_static_caption_segments(
    38|    word_cues: Sequence[WordCue],
    39|    *,
    40|    min_words: int = 3,
    41|    target_words: int = 6,
    42|    max_words: int = 7,
    43|) -> tuple[StaticCaptionSegment, ...]:
    44|    if min_words < 2:
    45|        raise ValueError("min_words must be >= 2")
    46|    if target_words < min_words:
    47|        raise ValueError("target_words must be >= min_words")
    48|    if max_words < target_words:
    49|        raise ValueError("max_words must be >= target_words")
    50|
    51|    grouped: list[list[tuple[int, WordCue]]] = []
    52|    current_sentence: str | None = None
    53|    current: list[tuple[int, WordCue]] = []
    54|    for index, cue in enumerate(word_cues):
    55|        sentence_key = cue.sentence or "__no_sentence__"
    56|        if current and sentence_key != current_sentence:
    57|            grouped.append(current)
    58|            current = []
    59|        current_sentence = sentence_key
    60|        current.append((index, cue))
    61|    if current:
    62|        grouped.append(current)
    63|
    64|    segments: list[StaticCaptionSegment] = []
    65|    for group in grouped:
    66|        chunks: list[list[tuple[int, WordCue]]] = []
    67|        i = 0
    68|        while i < len(group):
    69|            remaining = len(group) - i
    70|            size = min(target_words, remaining)
    71|            if remaining < min_words and chunks:
    72|                chunks[-1].extend(group[i:])
    73|                break
    74|            if size < min_words and len(group) >= min_words:
    75|                start = max(0, len(group) - min_words)
    76|                chunks.append(group[start:])
    77|                break
    78|            chunks.append(group[i : i + size])
    79|            i += size
    80|        for chunk in chunks:
    81|            if not chunk:
    82|                continue
    83|            if len(chunk) > max_words:
    84|                # Split oversized merged chunks conservatively while preserving min_words.
    85|                while len(chunk) > max_words:
    86|                    head = chunk[:max_words]
    87|                    segments.append(_segment_from_chunk(head))
    88|                    chunk = chunk[max_words:]
    89|                if 0 < len(chunk) < min_words and segments:
    90|                    previous = segments.pop()
    91|                    prev_pairs = [(idx, word_cues[idx]) for idx in previous.word_indices]
    92|                    chunk = prev_pairs + chunk
    93|            segments.append(_segment_from_chunk(chunk))
    94|    return tuple(segments)
    95|
    96|
    97|def _segment_from_chunk(chunk: Sequence[tuple[int, WordCue]]) -> StaticCaptionSegment:
    98|    return StaticCaptionSegment(
    99|        start=chunk[0][1].start,
   100|        end=chunk[-1][1].end,
   101|        word_indices=tuple(index for index, _ in chunk),
   102|        words=tuple(cue.word for _, cue in chunk),
   103|        sentence=chunk[0][1].sentence,
   104|    )
   105|
   106|
   107|def active_word_index_at(word_cues: Sequence[WordCue], timestamp: float) -> int:
   108|    for index, cue in enumerate(word_cues):
   109|        if cue.start <= timestamp < cue.end:
   110|            return index
   111|    previous = [index for index, cue in enumerate(word_cues) if cue.start <= timestamp]
   112|    return previous[-1] if previous else 0
   113|
   114|
   115|def segment_for_active_word(segments: Sequence[StaticCaptionSegment], active_word_index: int) -> StaticCaptionSegment:
   116|    for segment in segments:
   117|        if active_word_index in segment.word_indices:
   118|            return segment
   119|    raise ValueError(f"active word index {active_word_index} is not present in any segment")
   120|
   121|
   122|def caption_state_at(word_cues: Sequence[WordCue], segments: Sequence[StaticCaptionSegment], timestamp: float) -> dict[str, object]:
   123|    active_index = active_word_index_at(word_cues, timestamp)
   124|    segment = segment_for_active_word(segments, active_index)
   125|    return {
   126|        "segment_text": segment.text,
   127|        "segment_words": segment.words,
   128|        "word_indices": segment.word_indices,
   129|        "active_word_index": active_index,
   130|        "active_word": word_cues[active_index].word,
   131|    }
   132|


     1|from autoshorts.rendering.static_captions import (
     2|    WordCue,
     3|    active_word_index_at,
     4|    build_static_caption_segments,
     5|    caption_state_at,
     6|    segment_for_active_word,
     7|)
     8|
     9|
    10|def cues():
    11|    words = "Your AI summary is useless because it has no decision target".split()
    12|    return tuple(
    13|        WordCue(start=i * 0.4, end=(i + 1) * 0.4, word=word, sentence="Your AI summary is useless because it has no decision target")
    14|        for i, word in enumerate(words)
    15|    )
    16|
    17|
    18|def test_static_caption_segment_contains_multiple_words():
    19|    segments = build_static_caption_segments(cues())
    20|
    21|    assert segments
    22|    assert all(len(segment.words) >= 3 for segment in segments)
    23|    assert segments[0].text == "Your AI summary is useless because"
    24|
    25|
    26|def test_active_word_changes_within_same_static_segment():
    27|    word_cues = cues()
    28|    segments = build_static_caption_segments(word_cues)
    29|
    30|    early = caption_state_at(word_cues, segments, 0.1)
    31|    later = caption_state_at(word_cues, segments, 1.1)
    32|
    33|    assert early["segment_text"] == later["segment_text"]
    34|    assert early["active_word"] == "Your"
    35|    assert later["active_word"] == "summary"
    36|
    37|
    38|def test_segment_text_remains_stable_while_highlight_moves_left_to_right():
    39|    word_cues = cues()
    40|    segments = build_static_caption_segments(word_cues)
    41|    state_a = caption_state_at(word_cues, segments, 0.5)
    42|    state_b = caption_state_at(word_cues, segments, 1.7)
    43|
    44|    assert state_a["segment_words"] == state_b["segment_words"]
    45|    assert state_a["active_word_index"] < state_b["active_word_index"]
    46|
    47|
    48|def test_no_one_word_only_caption_mode_as_default_when_enough_words_exist():
    49|    segments = build_static_caption_segments(cues())
    50|
    51|    assert min(len(segment.words) for segment in segments) >= 3
    52|


Explanation:
**Looking for render script**

I need to find the render script in the /tmp directory, which probably requires some manual work. I’m considering searching for the preview_v1 code. It could be possible that the render_metadata has the script path I'm looking for. I’ll need to check that out to see if it leads me to the right spot. Let's see if I can make this process smoother!


     1|{
     2|  "candidate_id": "digital-red-flags-fake-invoice-payment-route-30s",
     3|  "version": "preview_v1",
     4|  "new_ai_images": false,
     5|  "wan_used": false,
     6|  "i2v_used": false,
     7|  "platform_api": false,
     8|  "artifacts": {
     9|    "preview_mp4": {
    10|      "file_path": "/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v1/digital-red-flags-fake-invoice-payment-route-30s_preview_v1.mp4",
    11|      "file_exists": true,
    12|      "file_size": 1270875,
    13|      "sha256": "bc8256104d1db71de6277b64db351ac3e1282d33396015ef09439aff033c47ba",
    14|      "media_type": "video/mp4",
    15|      "telegram_delivery_attempted": true,
    16|      "telegram_delivery_verified": true,
    17|      "telegram_message_id": "5951",
    18|      "duration": 31.291667,
    19|      "resolution": "1080x1920",
    20|      "fps": "24/1",
    21|      "telegram_target": "telegram:JARVIS Command Center / topic 4850"
    22|    },
    23|    "silent_mp4": {
    24|      "file_path": "/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v1/digital-red-flags-fake-invoice-payment-route-30s_preview_v1_silent.mp4",
    25|      "file_exists": true,
    26|      "file_size": 930007,
    27|      "sha256": "eec0f95eccc788fb9a8b9811d0b5175304fd135fe3d7d145fa49488714a5b85e",
    28|      "media_type": "video/mp4",
    29|      "telegram_delivery_attempted": true,
    30|      "telegram_delivery_verified": true,
    31|      "telegram_message_id": "5951",
    32|      "duration": 31.291667,
    33|      "resolution": "1080x1920",
    34|      "fps": "24/1",
    35|      "telegram_target": "telegram:JARVIS Command Center / topic 4850"
    36|    },
    37|    "contact_sheet": {
    38|      "file_path": "/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v1/digital-red-flags_contact_sheet_v1.jpg",
    39|      "file_exists": true,
    40|      "file_size": 79590,
    41|      "sha256": "bf2d7131fb10d2065221d41f3a2abdd0b6e61bdb5832fa7e9b5dc7fd0a9f888e",
    42|      "media_type": "image/jpeg",
    43|      "telegram_delivery_attempted": true,
    44|      "telegram_delivery_verified": true,
    45|      "telegram_message_id": "5951",
    46|      "duration": "n/a",
    47|      "resolution": "1080x720",
    48|      "telegram_target": "telegram:JARVIS Command Center / topic 4850"
    49|    },
    50|    "voiceover": {
    51|      "file_path": "/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/audio/voiceover.mp3",
    52|      "file_exists": true,
    53|      "file_size": 187920,
    54|      "sha256": "4e9da013d7c4baf1ddddbba9d0900471a0ffea243325b52bf02cf0f56f643bf5",
    55|      "media_type": "audio/mpeg",
    56|      "telegram_delivery_attempted": true,
    57|      "telegram_delivery_verified": true,
    58|      "telegram_message_id": "5951",
    59|      "duration": 31.32,
    60|      "resolution": "n/a",
    61|      "telegram_target": "telegram:JARVIS Command Center / topic 4850"
    62|    }
    63|  },
    64|  "caption_style": "static phrase segments; white words; yellow active word; no box/bar; subtle outline",
    65|  "thread_delivery": {
    66|    "telegram_delivery_attempted": true,
    67|    "telegram_delivery_verified": true,
    68|    "telegram_message_id": "5951",
    69|    "chat_id": "-1003907117629",
    70|    "target": "telegram:JARVIS Command Center / topic 4850"
    71|  }
    72|}


Explanation:
**Planning image rendering**

I’m thinking I might need to generate something with a script, but I don’t want to keep the old one. I'll create a new rendering script using PIL. First, though, I need to set up TDD tests and conduct an image audit. I should search the cached images to check if any are missing. I wonder if the search didn’t find them because they are hidden or ignored. I'll use the terminal with Python to list files.

from hermes_tools import terminal, search_files
print('Hermes image provider files')
print(terminal('python - <<\'PY\'\nfrom pathlib import Path\nfor root in [Path.home()/\'.hermes/hermes-agent/plugins/image_gen\', Path.home()/\'.hermes/hermes-agent/agent\', Path.home()/\'.hermes/hermes-agent/tools\']:\n print(\'ROOT\', root)\n if root.exists():\n  for p in root.rglob(\'*.py\'):\n   txt=p.read_text(errors=\'ignore\')\n   if \'gpt-image\' in txt or \'openai-codex\' in txt or \'image_gen\' in txt:\n    print(p)\nPY', timeout=30)['output'])
print('Logs relevant')
print(terminal('python - <<\'PY\'\nfrom pathlib import Path\nfor p in (Path.home()/\'.hermes/logs\').glob(\'*.log\'):\n print(\'---\', p)\n try:\n  lines=[l for l in p.read_text(errors=\'ignore\').splitlines() if any(s in l.lower() for s in [\'image\',\'gpt-image\',\'openai-codex\',\'styleframe\'])]\n  for l in lines[-30:]: print(l[:1000])\n except Exception as e: print(e)\nPY', timeout=30)['output'][:12000])
print('Image cache recent')
print(terminal('python - <<\'PY\'\nfrom pathlib import Path\nfor root in [Path.home()/\'.hermes/cache/images\', Path.home()/\'.hermes/cache\', Path(\'/tmp/autoshorts_proof_short_sprint30\'), Path.home()/\'jarvis_runtime/autoshortsbot/AutoShortsBot/data\']:\n print(\'ROOT\', root)\n if root.exists():\n  files=[p for p in root.rglob(\'*\') if p.suffix.lower() in [\'.png\',\'.jpg\',\'.jpeg\',\'.webp\',\'.json\']]\n  files=sorted(files, key=lambda p:p.stat().st_mtime, reverse=True)[:30]\n  for p in files:\n   print(p, p.stat().st_size)\nPY', timeout=30)['output'][:12000])


Output:
Hermes image provider files
ROOT /home/agent/.hermes/hermes-agent/plugins/image_gen
/home/agent/.hermes/hermes-agent/plugins/image_gen/openai-codex/__init__.py
/home/agent/.hermes/hermes-agent/plugins/image_gen/xai/__init__.py
/home/agent/.hermes/hermes-agent/plugins/image_gen/openai/__init__.py
ROOT /home/agent/.hermes/hermes-agent/agent
/home/agent/.hermes/hermes-agent/agent/image_gen_provider.py
/home/agent/.hermes/hermes-agent/agent/usage_pricing.py
/home/agent/.hermes/hermes-agent/agent/auxiliary_client.py
/home/agent/.hermes/hermes-agent/agent/models_dev.py
/home/agent/.hermes/hermes-agent/agent/credential_sources.py
/home/agent/.hermes/hermes-agent/agent/credential_pool.py
/home/agent/.hermes/hermes-agent/agent/video_gen_registry.py
/home/agent/.hermes/hermes-agent/agent/account_usage.py
/home/agent/.hermes/hermes-agent/agent/image_gen_registry.py
/home/agent/.hermes/hermes-agent/agent/model_metadata.py
/home/agent/.hermes/hermes-agent/agent/video_gen_provider.py
/home/agent/.hermes/hermes-agent/agent/display.py
/home/agent/.hermes/hermes-agent/agent/prompt_builder.py
/home/agent/.hermes/hermes-agent/agent/transports/hermes_tools_mcp_server.py
ROOT /home/agent/.hermes/hermes-agent/tools
/home/agent/.hermes/hermes-agent/tools/image_generation_tool.py
/home/agent/.hermes/hermes-agent/tools/delegate_tool.py
/home/agent/.hermes/hermes-agent/tools/debug_helpers.py
/home/agent/.hermes/hermes-agent/tools/video_generation_tool.py
/home/agent/.hermes/hermes-agent/tools/vision_tools.py
Logs relevant
--- /home/agent/.hermes/logs/gateway.log
2026-05-09 22:23:51,057 INFO gateway.platforms.telegram: [Telegram] Cached user photo at /home/agent/.hermes/image_cache/img_708f0a539545.jpg
2026-05-09 22:23:51,217 INFO gateway.platforms.telegram: [Telegram] Cached user photo at /home/agent/.hermes/image_cache/img_d2cda0fb3e48.jpg
2026-05-09 22:23:52,052 INFO gateway.run: Image routing: text (mode=text). Pre-analyzing 2 image(s) via vision_analyze.
2026-05-10 22:25:34,419 INFO gateway.run: Image cache cleanup: removed 2 stale file(s)
2026-05-12 17:07:50,371 WARNING gateway.platforms.telegram: [Telegram] send_media_group failed (chunk 1/1), falling back to per-image: Failed to send message #1 with the error message "photo_invalid_dimensions"
  File "/home/agent/.hermes/hermes-agent/gateway/platforms/telegram.py", line 2266, in send_multiple_images
2026-05-12 17:07:50,375 INFO gateway.platforms.base: [Telegram] Sending image: file:///home/agent/.hermes/cache/screenshots/browser_screenshot_9e7d3bcb51604... (alt=)
2026-05-12 17:07:50,717 INFO gateway.platforms.telegram: [Telegram] Image dimensions exceed Telegram photo limits, sending as document: /home/agent/.hermes/cache/screenshots/browser_screenshot_9e7d3bcb51604f9eb357e734b8d2eea0.png
2026-05-23 19:59:28,911 INFO gateway.run: inbound message: platform=telegram user=M G chat=-1003907117629 msg='Nutze es nun per ,image to video‘. Image kannst du per chatgpt pro erstellen las'
2026-05-24 09:12:19,045 INFO gateway.run: inbound message: platform=telegram user=M G chat=-1003907117629 msg='co/Civitai/Qwen-Image-2512-GGUF "Civitai/Qwen-Image-2512-GGUF · Hugging Face" So'
2026-05-24 23:52:33,586 INFO gateway.run: inbound message: platform=telegram user=M G chat=-1003907117629 msg='Ok, leg los. Bedenke: du kannst ChatGPT Image nutzen über codex anstelle eines l'
2026-05-25 14:18:02,944 INFO gateway.run: inbound message: platform=telegram user=M G chat=-1003907117629 msg='JARVIS, die drei Styleframe-Richtungen sind abgelehnt.  REJECT_STYLEFRAME_DIRECT'
2026-05-25 16:34:57,085 INFO gateway.run: inbound message: platform=telegram user=M G chat=-1003907117629 msg='JARVIS, die Premium-Styleframes A/B/C sind qualitativ deutlich besser als die Pi'
2026-05-25 16:58:58,723 INFO gateway.run: inbound message: platform=telegram user=M G chat=-1003907117629 msg='JARVIS, wir gehen jetzt in den nächsten kontrollierten Schritt.  Die Styleframe-'
--- /home/agent/.hermes/logs/agent.log
2026-05-25 19:17:42,665 INFO [20260525_170059_c017b0] run_agent: OpenAI client created (agent_init, shared=True) thread=asyncio_0:135595230156480 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:17:42,674 INFO [20260525_170059_c017b0] agent.auxiliary_client: Auxiliary auto-detect: using main provider openai-codex (gpt-5.5)
2026-05-25 19:17:42,676 INFO [20260525_170059_c017b0] run_agent: conversation turn: session=20260525_170059_c017b0 model=gpt-5.5 provider=openai-codex platform=telegram history=7 msg='[M G] JARVIS, das neue Fake-Invoice-Proof-Short-Konzept ist strategisch deutlich...'
2026-05-25 19:17:42,684 INFO run_agent: OpenAI client created (codex_stream_request, shared=False) thread=Thread-372 (_call):135594390841024 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:17:47,410 INFO run_agent: OpenAI client closed (request_complete, shared=False, tcp_force_closed=0) thread=Thread-372 (_call):135594390841024 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:17:47,410 INFO [20260525_170059_c017b0] run_agent: API call #1: model=gpt-5.5 provider=openai-codex in=22924 out=102 total=23026 latency=4.7s cache=15872/22924 (69%)
2026-05-25 19:17:47,540 INFO run_agent: OpenAI client created (codex_stream_request, shared=False) thread=Thread-373 (_call):135594390841024 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:17:52,404 INFO run_agent: OpenAI client closed (request_complete, shared=False, tcp_force_closed=0) thread=Thread-373 (_call):135594390841024 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:17:52,404 INFO [20260525_170059_c017b0] run_agent: API call #2: model=gpt-5.5 provider=openai-codex in=46965 out=136 total=47101 latency=4.9s cache=22528/46965 (48%)
2026-05-25 19:17:52,443 INFO run_agent: OpenAI client created (codex_stream_request, shared=False) thread=Thread-374 (_call):135594390841024 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:17:57,941 INFO run_agent: OpenAI client closed (request_complete, shared=False, tcp_force_closed=0) thread=Thread-374 (_call):135594390841024 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:17:57,941 INFO [20260525_170059_c017b0] run_agent: API call #3: model=gpt-5.5 provider=openai-codex in=47296 out=208 total=47504 latency=5.5s cache=46592/47296 (99%)
2026-05-25 19:18:17,054 INFO run_agent: OpenAI client created (codex_stream_request, shared=False) thread=Thread-385 (_call):135595087087296 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:18:23,253 INFO run_agent: OpenAI client closed (request_complete, shared=False, tcp_force_closed=0) thread=Thread-385 (_call):135595087087296 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:18:23,253 INFO [20260525_170059_c017b0] run_agent: API call #4: model=gpt-5.5 provider=openai-codex in=50463 out=255 total=50718 latency=6.2s cache=47104/50463 (93%)
2026-05-25 19:18:26,863 INFO run_agent: OpenAI client created (codex_stream_request, shared=False) thread=Thread-394 (_call):135594365662912 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:18:30,280 INFO run_agent: OpenAI client closed (request_complete, shared=False, tcp_force_closed=0) thread=Thread-394 (_call):135594365662912 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:18:30,280 INFO [20260525_170059_c017b0] run_agent: API call #5: model=gpt-5.5 provider=openai-codex in=53386 out=84 total=53470 latency=3.4s cache=50176/53386 (94%)
2026-05-25 19:18:30,817 INFO run_agent: OpenAI client created (codex_stream_request, shared=False) thread=Thread-397 (_call):135594365662912 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:18:33,247 INFO run_agent: OpenAI client closed (request_complete, shared=False, tcp_force_closed=0) thread=Thread-397 (_call):135594365662912 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:18:33,248 INFO [20260525_170059_c017b0] run_agent: API call #6: model=gpt-5.5 provider=openai-codex in=55592 out=48 total=55640 latency=2.4s cache=53248/55592 (96%)
2026-05-25 19:18:34,260 INFO run_agent: OpenAI client created (codex_stream_request, shared=False) thread=Thread-402 (_call):135594365662912 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:18:36,859 INFO run_agent: OpenAI client closed (request_complete, shared=False, tcp_force_closed=0) thread=Thread-402 (_call):135594365662912 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:18:36,859 INFO [20260525_170059_c017b0] run_agent: API call #7: model=gpt-5.5 provider=openai-codex in=57375 out=44 total=57419 latency=2.6s cache=55296/57375 (96%)
2026-05-25 19:18:37,868 INFO run_agent: OpenAI client created (codex_stream_request, shared=False) thread=Thread-407 (_call):135594365662912 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:18:42,923 INFO run_agent: OpenAI client closed (request_complete, shared=False, tcp_force_closed=0) thread=Thread-407 (_call):135594365662912 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:18:42,924 INFO [20260525_170059_c017b0] run_agent: API call #8: model=gpt-5.5 provider=openai-codex in=58161 out=84 total=58245 latency=5.1s cache=56832/58161 (98%)
2026-05-25 19:18:43,929 INFO run_agent: OpenAI client created (codex_stream_request, shared=False) thread=Thread-412 (_call):135594365662912 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:18:57,379 INFO run_agent: OpenAI client closed (request_complete, shared=False, tcp_force_closed=0) thread=Thread-412 (_call):135594365662912 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5
2026-05-25 19:18:57,380 INFO [20260525_170059_c017b0] run_agent: API call #9: model=gpt-5.5 provider=openai-codex in=59610 out=637 total=60247 latency=13.5s cache=57856/59610 (97%)
--- /home/agent/.hermes/logs/mcp-stderr.log
--- /home/agent/.hermes/logs/gateway-exit-diag.log
--- /home/agent/.hermes/logs/gateway-shutdown-diag.log
--- /home/agent/.hermes/logs/errors.log
2026-05-24 17:40:29,128 WARNING [20260524_120031_1cfb85] run_agent: API call failed (attempt 1/3) error_type=TimeoutError thread=asyncio_2:125596109629120 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5 summary=Non-streaming API call timed out after 900s with no response (threshold: 900s)
2026-05-24 17:40:29,129 WARNING [20260524_120031_1cfb85] run_agent: Retrying API call in 2.388389115417217s (attempt 1/3) thread=asyncio_2:125596109629120 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5 error=Non-streaming API call timed out after 900s with no response (threshold: 900s)
2026-05-24 18:55:16,868 WARNING [20260524_171930_769d7a] run_agent: API call failed (attempt 1/3) error_type=TimeoutError thread=asyncio_1:125596118021824 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5 summary=Non-streaming API call timed out after 900s with no response (threshold: 900s)
2026-05-24 18:55:16,869 WARNING [20260524_171930_769d7a] run_agent: Retrying API call in 2.324133697850045s (attempt 1/3) thread=asyncio_1:125596118021824 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5 error=Non-streaming API call timed out after 900s with no response (threshold: 900s)
2026-05-24 20:09:48,031 WARNING [20260524_171930_769d7a] run_agent: API call failed (attempt 1/3) error_type=ReadError thread=asyncio_1:125596118021824 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5 summary=[SSL] record layer failure (_ssl.c:2590)
2026-05-24 20:09:48,032 WARNING [20260524_171930_769d7a] run_agent: Retrying API call in 2.319087862098872s (attempt 1/3) thread=asyncio_1:125596118021824 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5 error=[SSL] record layer failure (_ssl.c:2590)
2026-05-24 20:24:52,640 WARNING [20260524_171930_769d7a] run_agent: API call failed (attempt 2/3) error_type=TimeoutError thread=asyncio_1:125596118021824 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5 summary=Non-streaming API call timed out after 900s with no response (threshold: 900s)
2026-05-24 20:24:52,641 WARNING [20260524_171930_769d7a] run_agent: Retrying API call in 4.059938401004144s (attempt 2/3) thread=asyncio_1:125596118021824 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5 error=Non-streaming API call timed out after 900s with no response (threshold: 900s)
2026-05-24 21:11:13,493 WARNING [20260524_171930_769d7a] run_agent: API call failed (attempt 1/3) error_type=InternalServerError thread=asyncio_0:125596214941376 provider=openai-codex base_url=https://chatgpt.com/backend-api/codex model=gpt-5.5 summary=HTTP 503: upstream connect error or disconnect/reset before headers. retried and the latest reset reason: connection timeout
2026-05-24 21:11:13,495 WARNING [20260524_171930
Image cache recent
ROOT /home/agent/.hermes/cache/images
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171425_7e5c5015.png 2342028
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171324_5e5577a3.png 2409107
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171159_68e5c2da.png 2502677
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171106_30a3b93c.png 1850047
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171011_d48ab227.png 1846484
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170919_3ff53fa7.png 1770763
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170840_142e83a8.png 2111685
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170731_5038e2ce.png 1819479
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170637_53794aad.png 1696594
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170531_da9173a6.png 1700699
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_163945_bde6f7ba.png 2308943
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_163829_a2a038ee.png 1745510
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_163710_c68f8acc.png 1828729
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_155120_1c40f80e.png 1780097
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_155007_ba06363c.png 1789550
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_154900_731eb2ec.png 1731013
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_140701_a23388d5.png 1761715
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_140617_e479d1b3.png 1622845
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_140515_a5e12b4c.png 1773981
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_133839_25bf7c79.png 1753538
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_133727_0d6c1c5d.png 1994994
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002425_90559d8e.png 1618656
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002346_15f5da53.png 1953680
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002251_1dfe8a3e.png 1794294
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002219_f22bec3f.png 1674074
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002134_d3c821b5.png 1573229
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260524_235629_ac459a00.png 1635446
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260524_093158_f770605c.png 1897139
ROOT /home/agent/.hermes/cache
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171425_7e5c5015.png 2342028
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171324_5e5577a3.png 2409107
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171159_68e5c2da.png 2502677
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171106_30a3b93c.png 1850047
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_171011_d48ab227.png 1846484
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170919_3ff53fa7.png 1770763
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170840_142e83a8.png 2111685
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170731_5038e2ce.png 1819479
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170637_53794aad.png 1696594
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_170531_da9173a6.png 1700699
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_163945_bde6f7ba.png 2308943
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_163829_a2a038ee.png 1745510
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_163710_c68f8acc.png 1828729
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_155120_1c40f80e.png 1780097
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_155007_ba06363c.png 1789550
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_154900_731eb2ec.png 1731013
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_140701_a23388d5.png 1761715
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_140617_e479d1b3.png 1622845
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_140515_a5e12b4c.png 1773981
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_133839_25bf7c79.png 1753538
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_133727_0d6c1c5d.png 1994994
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002425_90559d8e.png 1618656
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002346_15f5da53.png 1953680
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002251_1dfe8a3e.png 1794294
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002219_f22bec3f.png 1674074
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260525_002134_d3c821b5.png 1573229
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260524_235629_ac459a00.png 1635446
/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-medium_20260524_093158_f770605c.png 1897139
/home/agent/.hermes/cache/screenshots/browser_screenshot_8d15bc4f4cfb43d8ae38cf0b1566983d.png 358797
/home/agent/.hermes/cache/screenshots/browser_screenshot_8e5051fc1e4e461ea014d625821d338d.png 355824
ROOT /tmp/autoshorts_proof_short_sprint30
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v1/render_metadata_v1.json 3059
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/review_package_v2.json 10582
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v1/digital-red-flags_contact_sheet_v1.jpg 79590
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v1/qa_frames/frame_30s.jpg 61564
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v1/qa_frames/frame_25s.jpg 58764
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v1/qa_frames/frame_17s.jpg 76548
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v1/qa_frames/frame_10s.jpg 66878
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v1/qa_frames/frame_4s.jpg 49374
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/preview_v1/qa_frames/frame_1s.jpg 49193
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/review_package_v2_pre_render.json 9028
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/visual_plan_v1.json 1724
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/script_draft_v1.json 897
/tmp/autoshorts_proof_short_sprint30/digital-red-flags-fake-invoice-payment-route-30s/archive/ai-output-autopsy-decision-summary-38s_postmortem.json 1165
ROOT /home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01109.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01108.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01107.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01106.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01105.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01104.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01103.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01102.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01101.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01100.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01099.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01098.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01097.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01096.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01095.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01094.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01093.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01092.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01091.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01090.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01089.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01088.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01087.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace-ai-automation-quiet-rot-38s/frames/frame_01086.png 736943
/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/data/final_exports/truetrace-final-motion-v7-1080/truetrace


     1|"""OpenAI image generation backend — ChatGPT/Codex OAuth variant.
     2|
     3|Identical model catalog and tier semantics to the ``openai`` image-gen plugin
     4|(``gpt-image-2`` at low/medium/high quality), but routes the request through
     5|the Codex Responses API ``image_generation`` tool instead of the
     6|``images.generate`` REST endpoint. This lets users who are already
     7|authenticated with Codex/ChatGPT generate images without configuring a
     8|separate ``OPENAI_API_KEY``.
     9|
    10|Selection precedence for the tier (first hit wins):
    11|
    12|1. ``OPENAI_IMAGE_MODEL`` env var (escape hatch for scripts / tests)
    13|2. ``image_gen.openai-codex.model`` in ``config.yaml``
    14|3. ``image_gen.model`` in ``config.yaml`` (when it's one of our tier IDs)
    15|4. :data:`DEFAULT_MODEL` — ``gpt-image-2-medium``
    16|
    17|Output is saved as PNG under ``$HERMES_HOME/cache/images/``.
    18|"""
    19|
    20|from __future__ import annotations
    21|
    22|import logging
    23|from typing import Any, Dict, List, Optional, Tuple
    24|
    25|from agent.image_gen_provider import (
    26|    DEFAULT_ASPECT_RATIO,
    27|    ImageGenProvider,
    28|    error_response,
    29|    resolve_aspect_ratio,
    30|    save_b64_image,
    31|    success_response,
    32|)
    33|
    34|logger = logging.getLogger(__name__)
    35|
    36|
    37|# ---------------------------------------------------------------------------
    38|# Model catalog — mirrors the ``openai`` plugin so the picker UX is identical.
    39|# ---------------------------------------------------------------------------
    40|
    41|API_MODEL = "gpt-image-2"
    42|
    43|_MODELS: Dict[str, Dict[str, Any]] = {
    44|    "gpt-image-2-low": {
    45|        "display": "GPT Image 2 (Low)",
    46|        "speed": "~15s",
    47|        "strengths": "Fast iteration, lowest cost",
    48|        "quality": "low",
    49|    },
    50|    "gpt-image-2-medium": {
    51|        "display": "GPT Image 2 (Medium)",
    52|        "speed": "~40s",
    53|        "strengths": "Balanced — default",
    54|        "quality": "medium",
    55|    },
    56|    "gpt-image-2-high": {
    57|        "display": "GPT Image 2 (High)",
    58|        "speed": "~2min",
    59|        "strengths": "Highest fidelity, strongest prompt adherence",
    60|        "quality": "high",
    61|    },
    62|}
    63|
    64|DEFAULT_MODEL = "gpt-image-2-medium"
    65|
    66|_SIZES = {
    67|    "landscape": "1536x1024",
    68|    "square": "1024x1024",
    69|    "portrait": "1024x1536",
    70|}
    71|
    72|# Codex Responses surface used for the request. The chat model itself is only
    73|# the host that calls the ``image_generation`` tool; the actual image work is
    74|# done by ``API_MODEL``.
    75|_CODEX_CHAT_MODEL = "gpt-5.4"
    76|_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
    77|_CODEX_INSTRUCTIONS = (
    78|    "You are an assistant that must fulfill image generation requests by "
    79|    "using the image_generation tool when provided."
    80|)
    81|
    82|
    83|# ---------------------------------------------------------------------------
    84|# Config + auth helpers
    85|# ---------------------------------------------------------------------------
    86|
    87|
    88|def _load_image_gen_config() -> Dict[str, Any]:
    89|    """Read ``image_gen`` from config.yaml (returns {} on any failure)."""
    90|    try:
    91|        from hermes_cli.config import load_config
    92|
    93|        cfg = load_config()
    94|        section = cfg.get("image_gen") if isinstance(cfg, dict) else None
    95|        return section if isinstance(section, dict) else {}
    96|    except Exception as exc:
    97|        logger.debug("Could not load image_gen config: %s", exc)
    98|        return {}
    99|
   100|
   101|def _resolve_model() -> Tuple[str, Dict[str, Any]]:
   102|    """Decide which tier to use and return ``(model_id, meta)``."""
   103|    import os
   104|
   105|    env_override = os.environ.get("OPENAI_IMAGE_MODEL")
   106|    if env_override and env_override in _MODELS:
   107|        return env_override, _MODELS[env_override]
   108|
   109|    cfg = _load_image_gen_config()
   110|    sub = cfg.get("openai-codex") if isinstance(cfg.get("openai-codex"), dict) else {}
   111|    candidate: Optional[str] = None
   112|    if isinstance(sub, dict):
   113|        value = sub.get("model")
   114|        if isinstance(value, str) and value in _MODELS:
   115|            candidate = value
   116|    if candidate is None:
   117|        top = cfg.get("model")
   118|        if isinstance(top, str) and top in _MODELS:
   119|            candidate = top
   120|
   121|    if candidate is not None:
   122|        return candidate, _MODELS[candidate]
   123|
   124|    return DEFAULT_MODEL, _MODELS[DEFAULT_MODEL]
   125|
   126|
   127|def _read_codex_access_token() -> Optional[str]:
   128|    """Return a usable Codex OAuth token, or None.
   129|
   130|    Delegates to the canonical reader in ``agent.auxiliary_client`` so token
   131|    expiry, credential pool selection, and JWT decoding stay in one place.
   132|    """
   133|    try:
   134|        from agent.auxiliary_client import _read_codex_access_token as _reader
   135|
   136|        token = _reader()
   137|        if isinstance(token, str) and token.strip():
   138|            return token.strip()
   139|        return None
   140|    except Exception as exc:
   141|        logger.debug("Could not resolve Codex access token: %s", exc)
   142|        return None
   143|
   144|
   145|def _build_codex_client():
   146|    """Return an OpenAI client pointed at the ChatGPT/Codex backend, or None."""
   147|    token = _read_codex_access_token()
   148|    if not token:
   149|        return None
   150|    try:
   151|        import openai
   152|        from agent.auxiliary_client import _codex_cloudflare_headers
   153|
   154|        return openai.OpenAI(
   155|            api_key=token,
   156|            base_url=_CODEX_BASE_URL,
   157|            default_headers=_codex_cloudflare_headers(token),
   158|        )
   159|    except Exception as exc:
   160|        logger.debug("Could not build Codex image client: %s", exc)
   161|        return None
   162|
   163|
   164|def _collect_image_b64(client: Any, *, prompt: str, size: str, quality: str) -> Optional[str]:
   165|    """Stream a Codex Responses image_generation call and return the b64 image."""
   166|    image_b64: Optional[str] = None
   167|
   168|    with client.responses.stream(
   169|        model=_CODEX_CHAT_MODEL,
   170|        store=False,
   171|        instructions=_CODEX_INSTRUCTIONS,
   172|        input=[{
   173|            "type": "message",
   174|            "role": "user",
   175|            "content": [{"type": "input_text", "text": prompt}],
   176|        }],
   177|        tools=[{
   178|            "type": "image_generation",
   179|            "model": API_MODEL,
   180|            "size": size,
   181|            "quality": quality,
   182|            "output_format": "png",
   183|            "background": "opaque",
   184|            "partial_images": 1,
   185|        }],
   186|        tool_choice={
   187|            "type": "allowed_tools",
   188|            "mode": "required",
   189|            "tools": [{"type": "image_generation"}],
   190|        },
   191|    ) as stream:
   192|        for event in stream:
   193|            event_type = getattr(event, "type", "")
   194|            if event_type == "response.output_item.done":
   195|                item = getattr(event, "item", None)
   196|                if getattr(item, "type", None) == "image_generation_call":
   197|                    result = getattr(item, "result", None)
   198|                    if isinstance(result, str) and result:
   199|                        image_b64 = result
   200|            elif event_type == "response.image_generation_call.partial_image":
   201|                partial = getattr(event, "partial_image_b64", None)
   202|                if isinstance(partial, str) and partial:
   203|                    image_b64 = partial
   204|        final = stream.get_final_response()
   205|
   206|    # Final-response sweep covers the case where the stream finished before
   207|    # we observed the ``output_item.done`` event for the image call.
   208|    for item in getattr(final, "output", None) or []:
   209|        if getattr(item, "type", None) == "image_generation_call":
   210|            result = getattr(item, "result", None)
   211|            if isinstance(result, str) and result:
   212|                image_b64 = result
   213|
   214|    return image_b64
   215|
   216|
   217|# ---------------------------------------------------------------------------
   218|# Provider
   219|# ---------------------------------------------------------------------------
   220|
   221|
   222|class OpenAICodexImageGenProvider(ImageGenProvider):
   223|    """gpt-image-2 routed through ChatGPT/Codex OAuth instead of an API key."""
   224|
   225|    @property
   226|    def name(self) -> str:
   227|        return "openai-codex"
   228|
   229|    @property
   230|    def display_name(self) -> str:
   231|        return "OpenAI (Codex auth)"
   232|
   233|    def is_available(self) -> bool:
   234|        if not _read_codex_access_token():
   235|            return False
   236|        try:
   237|            import openai  # noqa: F401
   238|        except ImportError:
   239|            return False
   240|        return True
   241|
   242|    def list_models(self) -> List[Dict[str, Any]]:
   243|        return [
   244|            {
   245|                "id": model_id,
   246|                "display": meta["display"],
   247|                "speed": meta["speed"],
   248|                "strengths": meta["strengths"],
   249|                "price": "varies",
   250|            }
   251|            for model_id, meta in _MODELS.items()
   252|        ]
   253|
   254|    def default_model(self) -> Optional[str]:
   255|        return DEFAULT_MODEL
   256|
   257|    def get_setup_schema(self) -> Dict[str, Any]:
   258|        return {
   259|            "name": "OpenAI (Codex auth)",
   260|            "badge": "free",
   261|

Use offset=261 to continue reading (showing 1-260 of 378 lines)


     1|#!/usr/bin/env python3
     2|"""
     3|Image Generation Tools Module
     4|
     5|Provides image generation via FAL.ai. Multiple FAL models are supported and
     6|selectable via ``hermes tools`` → Image Generation; the active model is
     7|persisted to ``image_gen.model`` in ``config.yaml``.
     8|
     9|Architecture:
    10|- ``FAL_MODELS`` is a catalog of supported models with per-model metadata
    11|  (size-style family, defaults, ``supports`` whitelist, upscaler flag).
    12|- ``_build_fal_payload()`` translates the agent's unified inputs (prompt +
    13|  aspect_ratio) into the model-specific payload and filters to the
    14|  ``supports`` whitelist so models never receive rejected keys.
    15|- Upscaling via FAL's Clarity Upscaler is gated per-model via the ``upscale``
    16|  flag — on for FLUX 2 Pro (backward-compat), off for all faster/newer models
    17|  where upscaling would either hurt latency or add marginal quality.
    18|
    19|Pricing shown in UI strings is as-of the initial commit; we accept drift and
    20|update when it's noticed.
    21|"""
    22|
    23|import json
    24|import logging
    25|import os
    26|import datetime
    27|import threading
    28|import uuid
    29|from typing import Any, Dict, Optional, Union
    30|from urllib.parse import urlencode
    31|
    32|# fal_client is imported lazily — see _load_fal_client(). Pulling it
    33|# eagerly added ~64 ms to every CLI cold start because
    34|# discover_builtin_tools() imports this module unconditionally during
    35|# the registry walk, even when image generation is never used.
    36|#
    37|# Tests that monkeypatch this attribute (e.g.
    38|# ``monkeypatch.setattr(image_tool, "fal_client", fake_fal_client)``)
    39|# still work: _load_fal_client() short-circuits when the attribute is
    40|# anything truthy, so a test-installed mock is not overwritten by a
    41|# subsequent real import.
    42|fal_client: Any = None
    43|
    44|
    45|def _load_fal_client() -> Any:
    46|    """Lazily import fal_client and rebind the module global on first use.
    47|
    48|    Idempotent. Returns the (now-loaded) ``fal_client`` module reference.
    49|    Skips the import if the global is already truthy — this preserves the
    50|    test pattern of monkeypatching the module global to install a mock.
    51|    """
    52|    global fal_client
    53|    if fal_client is not None:
    54|        return fal_client
    55|    try:
    56|        from tools.lazy_deps import ensure as _lazy_ensure
    57|        _lazy_ensure("image.fal", prompt=False)
    58|    except ImportError:
    59|        pass
    60|    except Exception as e:
    61|        raise ImportError(str(e))
    62|    import fal_client as _fal_client  # noqa: F811 — module-global rebind
    63|    fal_client = _fal_client
    64|    return fal_client
    65|
    66|
    67|from tools.debug_helpers import DebugSession
    68|from tools.managed_tool_gateway import resolve_managed_tool_gateway
    69|from tools.tool_backend_helpers import (
    70|    fal_ke...ured,
    71|    managed_nous_tools_enabled,
    72|    prefers_gateway,
    73|)
    74|
    75|logger = logging.getLogger(__name__)
    76|
    77|
    78|# ---------------------------------------------------------------------------
    79|# FAL model catalog
    80|# ---------------------------------------------------------------------------
    81|#
    82|# Each entry declares how to translate our unified inputs into the model's
    83|# native payload shape. Size specification falls into three families:
    84|#
    85|#   "image_size_preset" — preset enum ("square_hd", "landscape_16_9", ...)
    86|#                          used by the flux family, z-image, qwen, recraft,
    87|#                          ideogram.
    88|#   "aspect_ratio"      — aspect ratio enum ("16:9", "1:1", ...) used by
    89|#                          nano-banana (Gemini).
    90|#   "gpt_literal"       — literal dimension strings ("1024x1024", etc.)
    91|#                          used by gpt-image-1.5.
    92|#
    93|# ``supports`` is a whitelist of keys allowed in the outgoing payload — any
    94|# key outside this set is stripped before submission so models never receive
    95|# rejected parameters (each FAL model rejects unknown keys differently).
    96|#
    97|# ``upscale`` controls whether to chain Clarity Upscaler after generation.
    98|
    99|FAL_MODELS: Dict[str, Dict[str, Any]] = {
   100|    "fal-ai/flux-2/klein/9b": {
   101|        "display": "FLUX 2 Klein 9B",
   102|        "speed": "<1s",
   103|        "strengths": "Fast, crisp text",
   104|        "price": "$0.006/MP",
   105|        "size_style": "image_size_preset",
   106|        "sizes": {
   107|            "landscape": "landscape_16_9",
   108|            "square": "square_hd",
   109|            "portrait": "portrait_16_9",
   110|        },
   111|        "defaults": {
   112|            "num_inference_steps": 4,
   113|            "output_format": "png",
   114|            "enable_safety_checker": False,
   115|        },
   116|        "supports": {
   117|            "prompt", "image_size", "num_inference_steps", "seed",
   118|            "output_format", "enable_safety_checker",
   119|        },
   120|        "upscale": False,
   121|    },
   122|    "fal-ai/flux-2-pro": {
   123|        "display": "FLUX 2 Pro",
   124|        "speed": "~6s",
   125|        "strengths": "Studio photorealism",
   126|        "price": "$0.03/MP",
   127|        "size_style": "image_size_preset",
   128|        "sizes": {
   129|            "landscape": "landscape_16_9",
   130|            "square": "square_hd",
   131|            "portrait": "portrait_16_9",
   132|        },
   133|        "defaults": {
   134|            "num_inference_steps": 50,
   135|            "guidance_scale": 4.5,
   136|            "num_images": 1,
   137|            "output_format": "png",
   138|            "enable_safety_checker": False,
   139|            "safety_tolerance": "5",
   140|            "sync_mode": True,
   141|        },
   142|        "supports": {
   143|            "prompt", "image_size", "num_inference_steps", "guidance_scale",
   144|            "num_images", "output_format", "enable_safety_checker",
   145|            "safety_tolerance", "sync_mode", "seed",
   146|        },
   147|        "upscale": True,   # Backward-compat: current default behavior.
   148|    },
   149|    "fal-ai/z-image/turbo": {
   150|        "display": "Z-Image Turbo",
   151|        "speed": "~2s",
   152|        "strengths": "Bilingual EN/CN, 6B",
   153|        "price": "$0.005/MP",
   154|        "size_style": "image_size_preset",
   155|        "sizes": {
   156|            "landscape": "landscape_16_9",
   157|            "square": "square_hd",
   158|            "portrait": "portrait_16_9",
   159|        },
   160|        "defaults": {
   161|            "num_inference_steps": 8,
   162|            "num_images": 1,
   163|            "output_format": "png",
   164|            "enable_safety_checker": False,
   165|            "enable_prompt_expansion": False,  # avoid the extra per-request charge
   166|        },
   167|        "supports": {
   168|            "prompt", "image_size", "num_inference_steps", "num_images",
   169|            "seed", "output_format", "enable_safety_checker",
   170|            "enable_prompt_expansion",
   171|        },
   172|        "upscale": False,
   173|    },
   174|    "fal-ai/nano-banana-pro": {
   175|        "display": "Nano Banana Pro (Gemini 3 Pro Image)",
   176|        "speed": "~8s",
   177|        "strengths": "Gemini 3 Pro, reasoning depth, text rendering",
   178|        "price": "$0.15/image (1K)",
   179|        "size_style": "aspect_ratio",
   180|        "sizes": {
   181|            "landscape": "16:9",
   182|            "square": "1:1",
   183|            "portrait": "9:16",
   184|        },
   185|        "defaults": {
   186|            "num_images": 1,
   187|            "output_format": "png",
   188|            "safety_tolerance": "5",
   189|            # "1K" is the cheapest tier; 4K doubles the per-image cost.
   190|            # Users on Nous Subscription should stay at 1K for predictable billing.
   191|            "resolution": "1K",
   192|        },
   193|        "supports": {
   194|            "prompt", "aspect_ratio", "num_images", "output_format",
   195|            "safety_tolerance", "seed", "sync_mode", "resolution",
   196|            "enable_web_search", "limit_generations",
   197|        },
   198|        "upscale": False,
   199|    },
   200|    "fal-ai/gpt-image-1.5": {
   201|        "display": "GPT Image 1.5",
   202|        "speed": "~15s",
   203|        "strengths": "Prompt adherence",
   204|        "price": "$0.034/image",
   205|        "size_style": "gpt_literal",
   206|        "sizes": {
   207|            "landscape": "1536x1024",
   208|            "square": "1024x1024",
   209|            "portrait": "1024x1536",
   210|        },
   211|        "defaults": {
   212|            # Quality is pinned to medium to keep portal billing predictable
   213|            # across all users (low is too rough, high is 4-6x more expensive).
   214|            "quality": "medium",
   215|            "num_images": 1,
   216|            "output_format": "png",
   217|        },
   218|        "supports": {
   219|            "prompt", "image_size", "quality", "num_images", "output_format",
   220|            "background", "sync_mode",
   221|

Use offset=221 to continue reading (showing 1-220 of 1098 lines)


     1|"""
     2|Image Generation Provider Registry
     3|==================================
     4|
     5|Central map of registered providers. Populated by plugins at import-time via
     6|``PluginContext.register_image_gen_provider()``; consumed by the
     7|``image_generate`` tool to dispatch each call to the active backend.
     8|
     9|Active selection
    10|----------------
    11|The active provider is chosen by ``image_gen.provider`` in ``config.yaml``.
    12|If unset, :func:`get_active_provider` applies fallback logic:
    13|
    14|1. If exactly one provider is registered, use it.
    15|2. Otherwise if a provider named ``fal`` is registered, use it (legacy
    16|   default — matches pre-plugin behavior).
    17|3. Otherwise return ``None`` (the tool surfaces a helpful error pointing
    18|   the user at ``hermes tools``).
    19|"""
    20|
    21|from __future__ import annotations
    22|
    23|import logging
    24|import threading
    25|from typing import Dict, List, Optional
    26|
    27|from agent.image_gen_provider import ImageGenProvider
    28|
    29|logger = logging.getLogger(__name__)
    30|
    31|
    32|_providers: Dict[str, ImageGenProvider] = {}
    33|_lock = threading.Lock()
    34|
    35|
    36|def register_provider(provider: ImageGenProvider) -> None:
    37|    """Register an image generation provider.
    38|
    39|    Re-registration (same ``name``) overwrites the previous entry and logs
    40|    a debug message — this makes hot-reload scenarios (tests, dev loops)
    41|    behave predictably.
    42|    """
    43|    if not isinstance(provider, ImageGenProvider):
    44|        raise TypeError(
    45|            f"register_provider() expects an ImageGenProvider instance, "
    46|            f"got {type(provider).__name__}"
    47|        )
    48|    name = provider.name
    49|    if not isinstance(name, str) or not name.strip():
    50|        raise ValueError("Image gen provider .name must be a non-empty string")
    51|    with _lock:
    52|        existing = _providers.get(name)
    53|        _providers[name] = provider
    54|    if existing is not None:
    55|        logger.debug("Image gen provider '%s' re-registered (was %r)", name, type(existing).__name__)
    56|    else:
    57|        logger.debug("Registered image gen provider '%s' (%s)", name, type(provider).__name__)
    58|
    59|
    60|def list_providers() -> List[ImageGenProvider]:
    61|    """Return all registered providers, sorted by name."""
    62|    with _lock:
    63|        items = list(_providers.values())
    64|    return sorted(items, key=lambda p: p.name)
    65|
    66|
    67|def get_provider(name: str) -> Optional[ImageGenProvider]:
    68|    """Return the provider registered under *name*, or None."""
    69|    if not isinstance(name, str):
    70|        return None
    71|    with _lock:
    72|        return _providers.get(name.strip())
    73|
    74|
    75|def get_active_provider() -> Optional[ImageGenProvider]:
    76|    """Resolve the currently-active provider.
    77|
    78|    Reads ``image_gen.provider`` from config.yaml; falls back per the
    79|    module docstring.
    80|
    81|    **Availability semantics** (mirrors :mod:`agent.web_search_registry`):
    82|
    83|    - When ``image_gen.provider`` is explicitly set, the configured
    84|      provider is returned even if :meth:`ImageGenProvider.is_available`
    85|      reports False — the dispatcher surfaces a precise "X_API_KEY is not
    86|      set" error rather than silently switching backends.
    87|    - When ``image_gen.provider`` is unset, the fallback path (single-
    88|      provider shortcut and the FAL legacy preference) is filtered by
    89|      ``is_available()`` so we don't pick a provider the user has no
    90|      credentials for.
    91|    """
    92|    configured: Optional[str] = None
    93|    try:
    94|        from hermes_cli.config import load_config
    95|
    96|        cfg = load_config()
    97|        section = cfg.get("image_gen") if isinstance(cfg, dict) else None
    98|        if isinstance(section, dict):
    99|            raw = section.get("provider")
   100|            if isinstance(raw, str) and raw.strip():
   101|                configured = raw.strip()
   102|    except Exception as exc:
   103|        logger.debug("Could not read image_gen.provider from config: %s", exc)
   104|
   105|    with _lock:
   106|        snapshot = dict(_providers)
   107|
   108|    def _is_available_safe(p: ImageGenProvider) -> bool:
   109|        """Wrap ``is_available()`` so a buggy provider doesn't kill resolution."""
   110|        try:
   111|            return bool(p.is_available())
   112|        except Exception as exc:  # noqa: BLE001
   113|            logger.debug("image_gen provider %s.is_available() raised %s", p.name, exc)
   114|            return False
   115|
   116|    # 1. Explicit config wins — return regardless of is_available() so the
   117|    #    user gets a precise downstream error message rather than a silent
   118|    #    backend switch.
   119|    if configured:
   120|        provider = snapshot.get(configured)
   121|        if provider is not None:
   122|            return provider
   123|        logger.debug(
   124|            "image_gen.provider='%s' configured but not registered; falling back",
   125|            configured,
   126|        )
   127|
   128|    # 2. Fallback: single registered provider — but only if it's actually
   129|    #    available (no credentials = don't surface it as "active").
   130|    available = [p for p in snapshot.values() if _is_available_safe(p)]
   131|    if len(available) == 1:
   132|        return available[0]
   133|
   134|    # 3. Fallback: prefer legacy FAL for backward compat, when available.
   135|    fal = snapshot.get("fal")
   136|    if fal is not None and _is_available_safe(fal):
   137|        return fal
   138|
   139|    return None
   140|
   141|
   142|def _reset_for_tests() -> None:
   143|    """Clear the registry. **Test-only.**"""
   144|    with _lock:
   145|        _providers.clear()
   146|


   260|            "badge": "free",
   261|            "tag": "gpt-image-2 via ChatGPT/Codex OAuth — no API key required",
   262|            "env_vars": [],
   263|            "post_setup_hint": (
   264|                "Sign in with `hermes auth codex` (or `hermes setup` → Codex) "
   265|                "if you haven't already. No API key needed."
   266|            ),
   267|        }
   268|
   269|    def generate(
   270|        self,
   271|        prompt: str,
   272|        aspect_ratio: str = DEFAULT_ASPECT_RATIO,
   273|        **kwargs: Any,
   274|    ) -> Dict[str, Any]:
   275|        prompt = (prompt or "").strip()
   276|        aspect = resolve_aspect_ratio(aspect_ratio)
   277|
   278|        if not prompt:
   279|            return error_response(
   280|                error="Prompt is required and must be a non-empty string",
   281|                error_type="invalid_argument",
   282|                provider="openai-codex",
   283|                aspect_ratio=aspect,
   284|            )
   285|
   286|        if not _read_codex_access_token():
   287|            return error_response(
   288|                error=(
   289|                    "No Codex/ChatGPT OAuth credentials available. Run "
   290|                    "`hermes auth codex` (or `hermes setup` → Codex) to sign in."
   291|                ),
   292|                error_type="auth_required",
   293|                provider="openai-codex",
   294|                aspect_ratio=aspect,
   295|            )
   296|
   297|        try:
   298|            import openai  # noqa: F401
   299|        except ImportError:
   300|            return error_response(
   301|                error="openai Python package not installed (pip install openai)",
   302|                error_type="missing_dependency",
   303|                provider="openai-codex",
   304|                aspect_ratio=aspect,
   305|            )
   306|
   307|        tier_id, meta = _resolve_model()
   308|        size = _SIZES.get(aspect, _SIZES["square"])
   309|
   310|        client = _build_codex_client()
   311|        if client is None:
   312|            return error_response(
   313|                error="Could not initialize Codex image client",
   314|                error_type="auth_required",
   315|                provider="openai-codex",
   316|                model=tier_id,
   317|                prompt=prompt,
   318|                aspect_ratio=aspect,
   319|            )
   320|
   321|        try:
   322|            b64 = _collect_image_b64(
   323|                client,
   324|                prompt=prompt,
   325|                size=size,
   326|                quality=meta["quality"],
   327|            )
   328|        except Exception as exc:
   329|            logger.debug("Codex image generation failed", exc_info=True)
   330|            return error_response(
   331|                error=f"OpenAI image generation via Codex auth failed: {exc}",
   332|                error_type="api_error",
   333|                provider="openai-codex",
   334|                model=tier_id,
   335|                prompt=prompt,
   336|                aspect_ratio=aspect,
   337|            )
   338|
   339|        if not b64:
   340|            return error_response(
   341|                error="Codex response contained no image_generation_call result",
   342|                error_type="empty_response",
   343|                provider="openai-codex",
   344|                model=tier_id,
   345|                prompt=prompt,
   346|                aspect_ratio=aspect,
   347|            )
   348|
   349|        try:
   350|            saved_path = save_b64_image(b64, prefix=f"openai_codex_{tier_id}")
   351|        except Exception as exc:
   352|            return error_response(
   353|                error=f"Could not save image to cache: {exc}",
   354|                error_type="io_error",
   355|                provider="openai-codex",
   356|                model=tier_id,
   357|                prompt=prompt,
   358|                aspect_ratio=aspect,
   359|            )
   360|
   361|        return success_response(
   362|            image=str(saved_path),
   363|            model=tier_id,
   364|            prompt=prompt,
   365|            aspect_ratio=aspect,
   366|            provider="openai-codex",
   367|            extra={"size": size, "quality": meta["quality"]},
   368|        )
   369|
   370|
   371|# ---------------------------------------------------------------------------
   372|# Plugin entry point
   373|# ---------------------------------------------------------------------------
   374|
   375|
   376|def register(ctx) -> None:
   377|    """Plugin entry point — register the Codex-backed image-gen provider."""
   378|    ctx.register_image_gen_provider(OpenAICodexImageGenProvider())
   379|


terminal

python - <<'PY'
import json, os, time, hashlib
from pathlib import Path
import sys
sys.path.insert(0, str(Path.home()/'.hermes/hermes-agent'))
from hermes_cli.plugins import _ensure_plugins_discovered
from hermes_cli.config import load_config
from agent.image_gen_registry import get_provider, list_providers
_ensure_plugins_discovered(force=True)
cfg=load_config().get('image_gen',{})
providers=[]
for p in list_providers():
    try: available=p.is_available()
    except Exception as e: available=f'error:{e}'
    models=[]
    try: models=p.list_models()
    except Exception as e: models=f'error:{e}'
    providers.append({'name':p.name,'display':getattr(p,'display_name',''),'available':available,'default_model':p.default_model(),'models':models})
prov=get_provider('openai-codex')
print(json.dumps({'config':cfg,'providers':providers,'openai_codex_available': bool(prov and prov.is_available())}, indent=2)[:12000])
# capability: single high quality image if provider available
outdir=Path('/tmp/autoshorts_proof_short_sprint30/image_provider_audit')
outdir.mkdir(parents=True, exist_ok=True)
result={'attempted': False}
if prov and prov.is_available():
    os.environ['OPENAI_IMAGE_MODEL']='gpt-image-2-high'
    prompt=("Neutral controlled styleframe capability test for AutoShortsBot: vertical 9:16 social-video keyframe, "
            "clean modern desk with a generic blank document mockup and soft studio light, no readable text, no logos, "
            "no brands, no QR codes, no people, safe margins, realistic product-film composition.")
    t=time.time()
    result=prov.generate(prompt, aspect_ratio='portrait')
    result['attempted']=True
    result['elapsed_seconds']=round(time.time()-t,2)
    if result.get('success') and result.get('image'):
        p=Path(result['image'])
        copy=outdir/('capability_test_'+p.name)
        copy.write_bytes(p.read_bytes())
        result['audit_copy']=str(copy)
        result['sha256']=hashlib.sha256(copy.read_bytes()).hexdigest()
        result['file_size']=copy.stat().st_size
        try:
            from PIL import Image
            im=Image.open(copy)
            result['resolution']=f'{im.width}x{im.height}'
            result['png_info_keys']=list(getattr(im,'info',{}).keys())
            result['png_info']={k: str(v)[:500] for k,v in im.info.items()}
        except Exception as e: result['pil_error']=str(e)
(Path(outdir/'capability_test_result.json')).write_text(json.dumps(result, indent=2), encoding='utf-8')
print('CAPABILITY_RESULT')
print(json.dumps(result, indent=2)[:12000])
PY