Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ VOICE_TOOLS_OPENAI_KEY=
# Only set to true if you intentionally want open access.
# GATEWAY_ALLOW_ALL_USERS=false

# Max output tokens per gateway response.
# Default when unset is 32768. Set to 0 to use the model/provider default.
# HERMES_MAX_TOKENS=32768

# =============================================================================
# RESPONSE PACING
# =============================================================================
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/venv/
# /upstream/ # Unignored temporarily to pull and merge upstream changes
/_pycache/
*.pyc*
__pycache__/
Expand Down
25 changes: 24 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

Instructions for AI coding assistants (GitHub Copilot, Cursor, etc.) and human developers.

NEVER PUSH TO REMOTE WITHOUT THE USER ASKING!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
Force encoding of things that could use another characterset to utf-8 encoding for safely running it on windows.

Hermes Agent is an AI agent harness with tool-calling capabilities, interactive CLI, messaging integrations, and scheduled tasks.

## Development Environment
Expand All @@ -21,7 +24,9 @@ hermes-agent/
│ ├── prompt_caching.py # Anthropic prompt caching
│ ├── prompt_builder.py # System prompt assembly (identity, skills index, context files)
│ ├── display.py # KawaiiSpinner, tool preview formatting
│ └── trajectory.py # Trajectory saving helpers
│ ├── trajectory.py # Trajectory saving helpers
│ ├── env_loader.py # .env loading with encoding fallback (Windows-safe)
│ └── text_io.py # Safe UTF-8 text file open (Windows-safe)
├── hermes_cli/ # CLI implementation
│ ├── main.py # Entry point, command dispatcher
│ ├── banner.py # Welcome banner, ASCII art, skills summary
Expand Down Expand Up @@ -88,6 +93,24 @@ Each tool file co-locates its schema, handler, and registration. `model_tools.py

---

## Encoding and Windows

On Windows the default text encoding is often CP1252. User content, tool output, and config files can contain UTF-8 or stray bytes, which causes `UnicodeDecodeError` / `UnicodeEncodeError` if we use the default encoding.

**Rules:**

1. **.env loading** — Always use `agent.env_loader.load_dotenv_with_fallback(path)` (never raw `load_dotenv(path)`). It reads bytes and tries UTF-8, UTF-8-sig, CP1252, Latin-1, then `errors="replace"`, so startup never crashes on a bad byte in `~/.hermes/.env`.

2. **Text file I/O** — For config, transcripts, JSON, YAML, logs, or any text that might contain non-ASCII:
- **Read:** `open(path, "r", encoding="utf-8", errors="replace")` or use `agent.text_io.open_text(path, "r")`.
- **Write/append:** `open(path, "w"|"a", encoding="utf-8", errors="replace", newline="")` or `agent.text_io.open_text(path, "w"|"a")`.

3. **Binary files** — Use `open(..., "rb")` or `"wb"`; do not pass `encoding`.

4. **CLI stdout/stderr** — `hermes_cli/main.py` calls `_ensure_utf8_stdio()` so Windows consoles get UTF-8 with `errors="replace"` when possible.

---

## AIAgent Class

The main agent is implemented in `run_agent.py`:
Expand Down
21 changes: 8 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Built by [Nous Research](https://nousresearch.com). Under the hood, the same arc

## Quick Install

**Linux / macOS / WSL:**
**Linux/macOS:**
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
Expand All @@ -42,25 +42,18 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri
irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex
```

**Windows (CMD):**
```cmd
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd
```

> **Windows note:** [Git for Windows](https://git-scm.com/download/win) is required. Hermes uses Git Bash internally for shell commands.

The installer will:
- Install [uv](https://docs.astral.sh/uv/) (fast Python package manager) if not present
- Install Python 3.11 via uv if not already available (no sudo needed)
- Clone to `~/.hermes/hermes-agent` (with submodules: mini-swe-agent, tinker-atropos)
- Create a virtual environment with Python 3.11
- Install all dependencies and submodule packages
- Set up the `hermes` command globally (no venv activation needed)
- Symlink `hermes` into `~/.local/bin` so it works globally (no venv activation needed)
- Run the interactive setup wizard

After installation, reload your shell and run:
```bash
source ~/.bashrc # or: source ~/.zshrc (Windows: restart your terminal)
source ~/.bashrc # or: source ~/.zshrc
hermes setup # Configure API keys (if you skipped during install)
hermes # Start chatting!
```
Expand Down Expand Up @@ -824,7 +817,8 @@ On Telegram, audio plays as native voice bubbles (the round, inline-playable kin
tts:
provider: "edge" # "edge" | "elevenlabs" | "openai"
edge:
voice: "en-US-AriaNeural" # 322 voices, 74 languages
voice: "en-US-AvaMultilingualNeural"
rate: "125%"
elevenlabs:
voice_id: "pNInz6obpgDQGcFmaJgB" # Adam
model_id: "eleven_multilingual_v2"
Expand Down Expand Up @@ -1244,8 +1238,8 @@ brew install git
brew install ripgrep node
```

**Windows (native):**
Hermes runs natively on Windows using [Git for Windows](https://git-scm.com/download/win) (which provides Git Bash for shell commands). Install Git for Windows first, then use the PowerShell or CMD quick-install command at the top of this README. WSL also works — follow the Ubuntu instructions above.
**Windows (WSL recommended):**
Use the [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install) and follow the Ubuntu instructions above. Alternatively, use the PowerShell quick-install script at the top of this README.

</details>

Expand Down Expand Up @@ -1685,6 +1679,7 @@ All variables go in `~/.hermes/.env`. Run `hermes config set VAR value` to set t
| Variable | Description |
|----------|-------------|
| `HERMES_MAX_ITERATIONS` | Max tool-calling iterations per conversation (default: 60) |
| `HERMES_MAX_TOKENS` | Max output tokens per gateway response (default: 32768 when unset; set `0` to use provider/model default) |
| `HERMES_TOOL_PROGRESS` | Send progress messages when using tools (`true`/`false`) |
| `HERMES_TOOL_PROGRESS_MODE` | `all` (every call, default) or `new` (only when tool changes) |

Expand Down
11 changes: 5 additions & 6 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,6 @@ def create(self, **kwargs) -> Any:
"store": False,
}

max_tokens = kwargs.get("max_output_tokens") or kwargs.get("max_completion_tokens") or kwargs.get("max_tokens")
if max_tokens is not None:
resp_kwargs["max_output_tokens"] = int(max_tokens)
if temperature is not None:
resp_kwargs["temperature"] = temperature

Expand Down Expand Up @@ -289,6 +286,9 @@ def get_text_auxiliary_client() -> Tuple[Optional[OpenAI], Optional[str]]:

Falls through OpenRouter -> Nous Portal -> custom endpoint -> Codex OAuth -> (None, None).
"""
global auxiliary_is_nous
auxiliary_is_nous = False

# 1. OpenRouter
or_key = os.getenv("OPENROUTER_API_KEY")
if or_key:
Expand All @@ -299,7 +299,6 @@ def get_text_auxiliary_client() -> Tuple[Optional[OpenAI], Optional[str]]:
# 2. Nous Portal
nous = _read_nous_auth()
if nous:
global auxiliary_is_nous
auxiliary_is_nous = True
logger.debug("Auxiliary text client: Nous Portal")
return (
Expand Down Expand Up @@ -382,7 +381,7 @@ def get_vision_auxiliary_client() -> Tuple[Optional[OpenAI], Optional[str]]:

def get_auxiliary_extra_body() -> dict:
"""Return extra_body kwargs for auxiliary API calls.

Includes Nous Portal product tags when the auxiliary client is backed
by Nous Portal. Returns empty dict otherwise.
"""
Expand All @@ -391,7 +390,7 @@ def get_auxiliary_extra_body() -> dict:

def auxiliary_max_tokens_param(value: int) -> dict:
"""Return the correct max tokens kwarg for the auxiliary client's provider.

OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer
models (gpt-4o, o-series, gpt-5+) requires 'max_completion_tokens'.
The Codex adapter translates max_tokens internally, so we use max_tokens
Expand Down
61 changes: 46 additions & 15 deletions agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def __init__(
model: str,
threshold_percent: float = 0.85,
protect_first_n: int = 3,
protect_last_n: int = 4,
protect_last_n: int = 8,
summary_target_tokens: int = 2500,
quiet_mode: bool = False,
summary_model_override: str = None,
Expand Down Expand Up @@ -80,9 +80,19 @@ def get_status(self) -> Dict[str, Any]:
}

def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> str:
"""Generate a concise summary of conversation turns using a fast model."""
"""Generate a structured summary of conversation turns using a fast model."""
if not self.client:
return "[CONTEXT SUMMARY]: Previous conversation turns have been compressed to save space. The assistant performed various actions and received responses."
return (
"[CONTEXT SUMMARY]:\n"
"active_topic: compressed_context\n"
"latest_user_goal: continue helping with the current request using available context\n"
"open_questions:\n"
"- none recorded\n"
"hard_constraints:\n"
"- do not drift topics\n"
"actions_taken:\n"
"- previous turns were compressed due to context limits"
)

parts = []
for msg in turns_to_summarize:
Expand All @@ -97,22 +107,33 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> str:
parts.append(f"[{role.upper()}]: {content}")

content_to_summarize = "\n\n".join(parts)
prompt = f"""Summarize these conversation turns concisely. This summary will replace these turns in the conversation history.

Write from a neutral perspective describing:
1. What actions were taken (tool calls, searches, file operations)
2. Key information or results obtained
3. Important decisions or findings
4. Relevant data, file names, or outputs

Keep factual and informative. Target ~{self.summary_target_tokens} tokens.
prompt = f"""Summarize these conversation turns into a STRUCTURED context record.
This summary will replace older turns in conversation history.
Keep factual, concise, and actionable. Target ~{self.summary_target_tokens} tokens.

Return EXACTLY this format:
[CONTEXT SUMMARY]:
active_topic: <single line>
latest_user_goal: <single line>
open_questions:
- <question 1 or 'none recorded'>
hard_constraints:
- <constraint 1, include do-not-drift guidance when applicable>
actions_taken:
- <important action/result 1>

Rules:
- Keep the last recent conversation details out of this summary; they remain verbatim elsewhere.
- Preserve critical constraints and unresolved asks.
- Include important tool outcomes, file paths, and decisions in actions_taken.
- Do not add any fields beyond the template above.

---
TURNS TO SUMMARIZE:
{content_to_summarize}
---

Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
Write only the structured summary."""

try:
kwargs = {
Expand All @@ -137,11 +158,21 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> str:

summary = response.choices[0].message.content.strip()
if not summary.startswith("[CONTEXT SUMMARY]:"):
summary = "[CONTEXT SUMMARY]: " + summary
summary = "[CONTEXT SUMMARY]:\n" + summary
return summary
except Exception as e:
logging.warning(f"Failed to generate context summary: {e}")
return "[CONTEXT SUMMARY]: Previous conversation turns have been compressed. The assistant performed tool calls and received responses."
return (
"[CONTEXT SUMMARY]:\n"
"active_topic: compressed_context\n"
"latest_user_goal: continue helping with the current request using available context\n"
"open_questions:\n"
"- none recorded\n"
"hard_constraints:\n"
"- do not drift topics\n"
"actions_taken:\n"
"- previous turns were compressed after summary model fallback"
)

def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None) -> List[Dict[str, Any]]:
"""Compress conversation messages by summarizing middle turns.
Expand Down
Loading