Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f10ae40d7 | |||
| c0243367a8 | |||
| 970bdda6c9 | |||
| 861e2f7683 | |||
| 21f89dd6bd | |||
| 7a06df8e05 | |||
| 69a59890ff | |||
| b67712beec | |||
| f65a97f6b2 | |||
| afb85c21b1 |
@@ -30,15 +30,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@v5
|
- uses: snok/install-poetry@v1
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
uv sync --frozen
|
poetry install --with dev
|
||||||
uv run pyinstaller strix.spec --noconfirm
|
poetry run pyinstaller strix.spec --noconfirm
|
||||||
|
|
||||||
VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
VERSION=$(poetry version -s)
|
||||||
mkdir -p dist/release
|
mkdir -p dist/release
|
||||||
|
|
||||||
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
||||||
|
|||||||
+12
@@ -39,6 +39,18 @@ pip-delete-this-directory.txt
|
|||||||
.pydevproject
|
.pydevproject
|
||||||
.settings/
|
.settings/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
# FastAPI
|
# FastAPI
|
||||||
.env.local
|
.env.local
|
||||||
.env.development.local
|
.env.development.local
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ repos:
|
|||||||
types-python-dateutil,
|
types-python-dateutil,
|
||||||
pydantic,
|
pydantic,
|
||||||
fastapi,
|
fastapi,
|
||||||
pytest,
|
|
||||||
"openai-agents[litellm]==0.14.6",
|
|
||||||
]
|
]
|
||||||
args: [--install-types, --non-interactive]
|
args: [--install-types, --non-interactive]
|
||||||
|
|
||||||
@@ -33,7 +31,6 @@ repos:
|
|||||||
- id: check-toml
|
- id: check-toml
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
args: ['--maxkb=1024']
|
|
||||||
- id: debug-statements
|
- id: debug-statements
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: check-docstring-first
|
- id: check-docstring-first
|
||||||
@@ -47,7 +44,7 @@ repos:
|
|||||||
|
|
||||||
# Additional Python code quality checks
|
# Additional Python code quality checks
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v3.21.2
|
rev: v3.20.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args: [--py312-plus]
|
args: [--py312-plus]
|
||||||
|
|||||||
+4
-4
@@ -8,7 +8,7 @@ Thank you for your interest in contributing to Strix! This guide will help you g
|
|||||||
|
|
||||||
- Python 3.12+
|
- Python 3.12+
|
||||||
- Docker (running)
|
- Docker (running)
|
||||||
- [uv](https://docs.astral.sh/uv/) (for dependency management)
|
- Poetry (for dependency management)
|
||||||
- Git
|
- Git
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
@@ -24,8 +24,8 @@ Thank you for your interest in contributing to Strix! This guide will help you g
|
|||||||
make setup-dev
|
make setup-dev
|
||||||
|
|
||||||
# or manually:
|
# or manually:
|
||||||
uv sync
|
poetry install --with=dev
|
||||||
uv run pre-commit install
|
poetry run pre-commit install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Configure your LLM provider**
|
3. **Configure your LLM provider**
|
||||||
@@ -36,7 +36,7 @@ Thank you for your interest in contributing to Strix! This guide will help you g
|
|||||||
|
|
||||||
4. **Run Strix in development mode**
|
4. **Run Strix in development mode**
|
||||||
```bash
|
```bash
|
||||||
uv run strix --target https://example.com
|
poetry run strix --target https://example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📚 Contributing Skills
|
## 📚 Contributing Skills
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev
|
.PHONY: help install dev-install format lint type-check test test-cov clean pre-commit setup-dev
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Available commands:"
|
@echo "Available commands:"
|
||||||
@@ -8,63 +8,83 @@ help:
|
|||||||
@echo ""
|
@echo ""
|
||||||
@echo "Code Quality:"
|
@echo "Code Quality:"
|
||||||
@echo " format - Format code with ruff"
|
@echo " format - Format code with ruff"
|
||||||
@echo " lint - Lint code with ruff"
|
@echo " lint - Lint code with ruff and pylint"
|
||||||
@echo " type-check - Run type checking with mypy and pyright"
|
@echo " type-check - Run type checking with mypy and pyright"
|
||||||
@echo " security - Run security checks with bandit"
|
@echo " security - Run security checks with bandit"
|
||||||
@echo " check-all - Run all code quality checks"
|
@echo " check-all - Run all code quality checks"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
@echo "Testing:"
|
||||||
|
@echo " test - Run tests with pytest"
|
||||||
|
@echo " test-cov - Run tests with coverage reporting"
|
||||||
|
@echo ""
|
||||||
@echo "Development:"
|
@echo "Development:"
|
||||||
@echo " pre-commit - Run pre-commit hooks on all files"
|
@echo " pre-commit - Run pre-commit hooks on all files"
|
||||||
@echo " clean - Clean up cache files and artifacts"
|
@echo " clean - Clean up cache files and artifacts"
|
||||||
|
|
||||||
install:
|
install:
|
||||||
uv sync --no-dev
|
poetry install --only=main
|
||||||
|
|
||||||
dev-install:
|
dev-install:
|
||||||
uv sync
|
poetry install --with=dev
|
||||||
|
|
||||||
setup-dev: dev-install
|
setup-dev: dev-install
|
||||||
uv run pre-commit install
|
poetry run pre-commit install
|
||||||
@echo "✅ Development environment setup complete!"
|
@echo "✅ Development environment setup complete!"
|
||||||
@echo "Run 'make check-all' to verify everything works correctly."
|
@echo "Run 'make check-all' to verify everything works correctly."
|
||||||
|
|
||||||
format:
|
format:
|
||||||
@echo "🎨 Formatting code with ruff..."
|
@echo "🎨 Formatting code with ruff..."
|
||||||
uv run ruff format .
|
poetry run ruff format .
|
||||||
@echo "✅ Code formatting complete!"
|
@echo "✅ Code formatting complete!"
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@echo "🔍 Linting code with ruff..."
|
@echo "🔍 Linting code with ruff..."
|
||||||
uv run ruff check . --fix
|
poetry run ruff check . --fix
|
||||||
|
@echo "📝 Running additional linting with pylint..."
|
||||||
|
poetry run pylint strix/ --score=no --reports=no
|
||||||
@echo "✅ Linting complete!"
|
@echo "✅ Linting complete!"
|
||||||
|
|
||||||
type-check:
|
type-check:
|
||||||
@echo "🔍 Type checking with mypy..."
|
@echo "🔍 Type checking with mypy..."
|
||||||
uv run mypy strix/
|
poetry run mypy strix/
|
||||||
@echo "🔍 Type checking with pyright..."
|
@echo "🔍 Type checking with pyright..."
|
||||||
uv run pyright strix/
|
poetry run pyright strix/
|
||||||
@echo "✅ Type checking complete!"
|
@echo "✅ Type checking complete!"
|
||||||
|
|
||||||
security:
|
security:
|
||||||
@echo "🔒 Running security checks with bandit..."
|
@echo "🔒 Running security checks with bandit..."
|
||||||
uv run bandit -r strix/ -c pyproject.toml
|
poetry run bandit -r strix/ -c pyproject.toml
|
||||||
@echo "✅ Security checks complete!"
|
@echo "✅ Security checks complete!"
|
||||||
|
|
||||||
check-all: format lint type-check security
|
check-all: format lint type-check security
|
||||||
@echo "✅ All code quality checks passed!"
|
@echo "✅ All code quality checks passed!"
|
||||||
|
|
||||||
|
test:
|
||||||
|
@echo "🧪 Running tests..."
|
||||||
|
poetry run pytest -v
|
||||||
|
@echo "✅ Tests complete!"
|
||||||
|
|
||||||
|
test-cov:
|
||||||
|
@echo "🧪 Running tests with coverage..."
|
||||||
|
poetry run pytest -v --cov=strix --cov-report=term-missing --cov-report=html
|
||||||
|
@echo "✅ Tests with coverage complete!"
|
||||||
|
@echo "📊 Coverage report generated in htmlcov/"
|
||||||
|
|
||||||
pre-commit:
|
pre-commit:
|
||||||
@echo "🔧 Running pre-commit hooks..."
|
@echo "🔧 Running pre-commit hooks..."
|
||||||
uv run pre-commit run --all-files
|
poetry run pre-commit run --all-files
|
||||||
@echo "✅ Pre-commit hooks complete!"
|
@echo "✅ Pre-commit hooks complete!"
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@echo "🧹 Cleaning up cache files..."
|
@echo "🧹 Cleaning up cache files..."
|
||||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||||
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
|
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||||
find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
|
find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find . -type d -name "htmlcov" -exec rm -rf {} + 2>/dev/null || true
|
||||||
find . -name "*.pyc" -delete 2>/dev/null || true
|
find . -name "*.pyc" -delete 2>/dev/null || true
|
||||||
|
find . -name ".coverage" -delete 2>/dev/null || true
|
||||||
@echo "✅ Cleanup complete!"
|
@echo "✅ Cleanup complete!"
|
||||||
|
|
||||||
dev: format lint type-check
|
dev: format lint type-check test
|
||||||
@echo "✅ Development cycle complete!"
|
@echo "✅ Development cycle complete!"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
# Strix
|
# Strix
|
||||||
|
|
||||||
### The open-source AI pentesting tool. Autonomous AI hackers that find and fix your app’s vulnerabilities.
|
### Open-source AI hackers to find and fix your app’s vulnerabilities.
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
@@ -32,23 +32,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **New!** Strix integrates seamlessly with GitHub Actions and CI/CD pipelines. Automatically scan for vulnerabilities on every pull request and block insecure code before it reaches production - [Get started with no setup required](https://app.strix.ai).
|
> **New!** Strix integrates seamlessly with GitHub Actions and CI/CD pipelines. Automatically scan for vulnerabilities on every pull request and block insecure code before it reaches production!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## Strix Overview
|
## Strix Overview
|
||||||
|
|
||||||
Strix are autonomous AI penetration testing agents that act just like real hackers - they run your code dynamically, find vulnerabilities, and validate them through actual proof-of-concepts. Built for developers and security teams who need fast, accurate security testing without the overhead of manual pentesting or the false positives of static analysis tools.
|
Strix are autonomous AI agents that act just like real hackers - they run your code dynamically, find vulnerabilities, and validate them through actual proof-of-concepts. Built for developers and security teams who need fast, accurate security testing without the overhead of manual pentesting or the false positives of static analysis tools.
|
||||||
|
|
||||||
**Key Capabilities:**
|
**Key Capabilities:**
|
||||||
|
|
||||||
- **Full pentesting toolkit** - reconnaissance, exploitation, and validation out of the box
|
- **Full hacker toolkit** out of the box
|
||||||
- **Multi-agent orchestration** - teams of AI pentesters that collaborate and scale
|
- **Teams of agents** that collaborate and scale
|
||||||
- **Real exploit validation** - working PoCs, not false positives like legacy vulnerability scanners
|
- **Real validation** with PoCs, not false positives
|
||||||
- **Developer‑first CLI** - actionable findings with remediation guidance
|
- **Developer‑first** CLI with actionable reports
|
||||||
- **Auto‑fix & reporting** - generate patches and compliance-ready pentest reports
|
- **Auto‑fix & reporting** to accelerate remediation
|
||||||
|
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -95,13 +96,13 @@ strix --target ./app-directory
|
|||||||
|
|
||||||
## ☁️ Strix Platform
|
## ☁️ Strix Platform
|
||||||
|
|
||||||
Try the Strix full-stack penetration testing platform at **[app.strix.ai](https://app.strix.ai)** - sign up for free, connect your repos and domains, and launch a pentest in minutes.
|
Try the Strix full-stack security platform at **[app.strix.ai](https://app.strix.ai)** — sign up for free, connect your repos and domains, and launch a pentest in minutes.
|
||||||
|
|
||||||
- **Validated findings with PoCs** - every vulnerability includes a working proof-of-concept exploit and reproduction steps
|
- **Validated findings with PoCs** and reproduction steps
|
||||||
- **One-click autofix** - AI-generated security patches as ready-to-merge pull requests
|
- **One-click autofix** as ready-to-merge pull requests
|
||||||
- **Continuous pentesting** - always-on vulnerability scanning that keeps pace with your deployments
|
- **Continuous monitoring** across code, cloud, and infrastructure
|
||||||
- **DevSecOps integrations** - GitHub, GitLab, Bitbucket, Slack, Jira, Linear, and CI/CD pipelines
|
- **Integrations** with GitHub, Slack, Jira, Linear, and CI/CD pipelines
|
||||||
- **Continuous learning** - AI that builds on past findings, adapts to your codebase, and reduces false positives over time
|
- **Continuous learning** that builds on past findings and remediations
|
||||||
|
|
||||||
[**Start your first pentest →**](https://app.strix.ai)
|
[**Start your first pentest →**](https://app.strix.ai)
|
||||||
|
|
||||||
@@ -109,38 +110,37 @@ Try the Strix full-stack penetration testing platform at **[app.strix.ai](https:
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
### Agentic Pentesting Tools
|
### Agentic Security Tools
|
||||||
|
|
||||||
Strix agents come equipped with a comprehensive offensive security toolkit - the same tools used by professional penetration testers and ethical hackers:
|
Strix agents come equipped with a comprehensive security testing toolkit:
|
||||||
|
|
||||||
- **HTTP Interception Proxy** - Full request/response manipulation and analysis with Caido
|
- **Full HTTP Proxy** - Full request/response manipulation and analysis
|
||||||
- **Browser Exploitation** - Automated browser for testing XSS, CSRF, clickjacking, and auth bypass flows
|
- **Browser Automation** - Multi-tab browser for testing of XSS, CSRF, auth flows
|
||||||
- **Shell & Command Execution** - Interactive terminal for exploit development and post-exploitation
|
- **Terminal Environments** - Interactive shells for command execution and testing
|
||||||
- **Custom Exploit Runtime** - Python sandbox for writing and validating proof-of-concept exploits
|
- **Python Runtime** - Custom exploit development and validation
|
||||||
- **Reconnaissance & OSINT** - Automated attack surface mapping, subdomain enumeration, and fingerprinting
|
- **Reconnaissance** - Automated OSINT and attack surface mapping
|
||||||
- **Static & Dynamic Code Analysis** - SAST + DAST capabilities for comprehensive application security testing
|
- **Code Analysis** - Static and dynamic analysis capabilities
|
||||||
- **Vulnerability Knowledge Base** - Structured findings with CVSS scoring and OWASP classification
|
- **Knowledge Management** - Structured findings and attack documentation
|
||||||
|
|
||||||
### Comprehensive Vulnerability Scanner
|
### Comprehensive Vulnerability Detection
|
||||||
|
|
||||||
Strix identifies, validates, and exploits a wide range of security vulnerabilities across the OWASP Top 10 and beyond:
|
Strix can identify and validate a wide range of security vulnerabilities:
|
||||||
|
|
||||||
- **Broken Access Control** - IDOR, privilege escalation, auth bypass
|
- **Access Control** - IDOR, privilege escalation, auth bypass
|
||||||
- **Injection Attacks** - SQL injection, NoSQL injection, OS command injection, SSTI
|
- **Injection Attacks** - SQL, NoSQL, command injection
|
||||||
- **Server-Side Vulnerabilities** - SSRF, XXE, insecure deserialization, RCE
|
- **Server-Side** - SSRF, XXE, deserialization flaws
|
||||||
- **Client-Side Attacks** - XSS (stored/reflected/DOM), prototype pollution, CSRF
|
- **Client-Side** - XSS, prototype pollution, DOM vulnerabilities
|
||||||
- **Business Logic Flaws** - Race conditions, payment manipulation, workflow bypass
|
- **Business Logic** - Race conditions, workflow manipulation
|
||||||
- **Authentication & Session** - JWT attacks, session fixation, credential stuffing vectors
|
- **Authentication** - JWT vulnerabilities, session management
|
||||||
- **Infrastructure & Cloud** - Misconfigurations, exposed services, cloud security issues
|
- **Infrastructure** - Misconfigurations, exposed services
|
||||||
- **API Security** - Broken authentication, mass assignment, rate limiting bypass
|
|
||||||
|
|
||||||
### Graph of Agents (Multi-Agent Pentesting)
|
### Graph of Agents
|
||||||
|
|
||||||
Advanced multi-agent orchestration for comprehensive automated penetration testing:
|
Advanced multi-agent orchestration for comprehensive security testing:
|
||||||
|
|
||||||
- **Distributed Pentesting** - Specialized AI agents for recon, exploitation, and post-exploitation
|
- **Distributed Workflows** - Specialized agents for different attacks and assets
|
||||||
- **Scalable Security Testing** - Parallel execution across multiple targets for fast, comprehensive coverage
|
- **Scalable Testing** - Parallel execution for fast comprehensive coverage
|
||||||
- **Dynamic Coordination** - Agents share discoveries, chain vulnerabilities, and collaborate like a red team
|
- **Dynamic Coordination** - Agents collaborate and share discoveries
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
|
|||||||
|
|
||||||
### Headless Mode
|
### Headless Mode
|
||||||
|
|
||||||
Run Strix programmatically without interactive UI using the `-n/--non-interactive` flag - perfect for servers and automated jobs. The CLI prints real-time vulnerability findings, and the final report before exiting. Exits with non-zero code when vulnerabilities are found.
|
Run Strix programmatically without interactive UI using the `-n/--non-interactive` flag—perfect for servers and automated jobs. The CLI prints real-time vulnerability findings, and the final report before exiting. Exits with non-zero code when vulnerabilities are found.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
strix -n --target https://your-app.com
|
strix -n --target https://your-app.com
|
||||||
@@ -240,19 +240,19 @@ export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high,
|
|||||||
|
|
||||||
**Recommended models for best results:**
|
**Recommended models for best results:**
|
||||||
|
|
||||||
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
|
- [OpenAI GPT-5.4](https://openai.com/api/) — `openai/gpt-5.4`
|
||||||
- [Anthropic Claude Sonnet 4.6](https://claude.com/platform/api) - `anthropic/claude-sonnet-4-6`
|
- [Anthropic Claude Sonnet 4.6](https://claude.com/platform/api) — `anthropic/claude-sonnet-4-6`
|
||||||
- [Google Gemini 3 Pro Preview](https://cloud.google.com/vertex-ai) - `vertex_ai/gemini-3-pro-preview`
|
- [Google Gemini 3 Pro Preview](https://cloud.google.com/vertex-ai) — `vertex_ai/gemini-3-pro-preview`
|
||||||
|
|
||||||
See the [LLM Providers documentation](https://docs.strix.ai/llm-providers/overview) for all supported providers including Vertex AI, Bedrock, Azure, and local models.
|
See the [LLM Providers documentation](https://docs.strix.ai/llm-providers/overview) for all supported providers including Vertex AI, Bedrock, Azure, and local models.
|
||||||
|
|
||||||
## Enterprise Pentesting
|
## Enterprise
|
||||||
|
|
||||||
Get the same Strix experience with [enterprise-grade](https://strix.ai/demo) controls: SSO (SAML/OIDC), custom compliance-ready penetration testing reports (SOC 2, ISO 27001, PCI DSS), dedicated support & SLA, custom deployment options (VPC/self-hosted), BYOK model support, and tailored AI pentesting agents optimized for your environment. [Learn more](https://strix.ai/demo).
|
Get the same Strix experience with [enterprise-grade](https://strix.ai/demo) controls: SSO (SAML/OIDC), custom compliance reports, dedicated support & SLA, custom deployment options (VPC/self-hosted), BYOK model support, and tailored agents optimized for your environment. [Learn more](https://strix.ai/demo).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Full documentation is available at **[docs.strix.ai](https://docs.strix.ai)** - including detailed guides for usage, CI/CD integrations, skills, and advanced configuration.
|
Full documentation is available at **[docs.strix.ai](https://docs.strix.ai)** — including detailed guides for usage, CI/CD integrations, skills, and advanced configuration.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -275,5 +275,3 @@ Strix builds on the incredible work of open-source projects like [LiteLLM](https
|
|||||||
> Only test apps you own or have permission to test. You are responsible for using Strix ethically and legally.
|
> Only test apps you own or have permission to test. You are responsible for using Strix ethically and legally.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||

|
|
||||||
|
|||||||
+42
-28
@@ -12,7 +12,14 @@ RUN useradd -m -s /bin/bash pentester && \
|
|||||||
echo "pentester ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers && \
|
echo "pentester ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers && \
|
||||||
touch /home/pentester/.hushlogin
|
touch /home/pentester/.hushlogin
|
||||||
|
|
||||||
RUN mkdir -p /home/pentester/tools /app/certs && \
|
RUN mkdir -p /home/pentester/configs \
|
||||||
|
/home/pentester/wordlists \
|
||||||
|
/home/pentester/output \
|
||||||
|
/home/pentester/scripts \
|
||||||
|
/home/pentester/tools \
|
||||||
|
/app/runtime \
|
||||||
|
/app/tools \
|
||||||
|
/app/certs && \
|
||||||
chown -R pentester:pentester /app/certs /home/pentester/tools
|
chown -R pentester:pentester /app/certs /home/pentester/tools
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
@@ -32,8 +39,11 @@ RUN apt-get update && \
|
|||||||
nodejs npm pipx \
|
nodejs npm pipx \
|
||||||
libcap2-bin \
|
libcap2-bin \
|
||||||
gdb \
|
gdb \
|
||||||
libnss3-tools \
|
tmux \
|
||||||
chromium fonts-liberation
|
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libatspi2.0-0 \
|
||||||
|
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libxkbcommon0 libpango-1.0-0 libcairo2 libasound2t64 \
|
||||||
|
fonts-unifont fonts-noto-color-emoji fonts-freefont-ttf fonts-dejavu-core ttf-bitstream-vera \
|
||||||
|
libnss3-tools
|
||||||
|
|
||||||
|
|
||||||
RUN setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip $(which nmap)
|
RUN setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip $(which nmap)
|
||||||
@@ -60,7 +70,11 @@ USER root
|
|||||||
RUN cp /app/certs/ca.crt /usr/local/share/ca-certificates/ca.crt && \
|
RUN cp /app/certs/ca.crt /usr/local/share/ca-certificates/ca.crt && \
|
||||||
update-ca-certificates
|
update-ca-certificates
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
|
RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python3 - && \
|
||||||
|
ln -s /opt/poetry/bin/poetry /usr/local/bin/poetry && \
|
||||||
|
chmod +x /usr/local/bin/poetry && \
|
||||||
|
python3 -m venv /app/venv && \
|
||||||
|
chown -R pentester:pentester /app/venv /opt/poetry
|
||||||
|
|
||||||
USER pentester
|
USER pentester
|
||||||
WORKDIR /tmp
|
WORKDIR /tmp
|
||||||
@@ -75,7 +89,7 @@ RUN nuclei -update-templates
|
|||||||
|
|
||||||
RUN pipx install arjun && \
|
RUN pipx install arjun && \
|
||||||
pipx install dirsearch && \
|
pipx install dirsearch && \
|
||||||
pipx inject dirsearch 'setuptools<81' && \
|
pipx inject dirsearch setuptools && \
|
||||||
pipx install wafw00f
|
pipx install wafw00f
|
||||||
|
|
||||||
ENV NPM_CONFIG_PREFIX=/home/pentester/.npm-global
|
ENV NPM_CONFIG_PREFIX=/home/pentester/.npm-global
|
||||||
@@ -85,14 +99,7 @@ RUN npm install -g retire@latest && \
|
|||||||
npm install -g eslint@latest && \
|
npm install -g eslint@latest && \
|
||||||
npm install -g js-beautify@latest && \
|
npm install -g js-beautify@latest && \
|
||||||
npm install -g @ast-grep/cli@latest && \
|
npm install -g @ast-grep/cli@latest && \
|
||||||
npm install -g tree-sitter-cli@latest && \
|
npm install -g tree-sitter-cli@latest
|
||||||
npm install -g agent-browser@0.26.0
|
|
||||||
|
|
||||||
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
|
|
||||||
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
||||||
ENV AGENT_BROWSER_ARGS="--disable-blink-features=AutomationControlled,--no-first-run,--no-default-browser-check,--lang=en-US"
|
|
||||||
ENV AGENT_BROWSER_SCREENSHOT_DIR=/workspace/.agent-browser-screenshots
|
|
||||||
RUN /home/pentester/.npm-global/bin/agent-browser doctor --offline --quick
|
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
TS_PARSER_DIR="/home/pentester/.tree-sitter/parsers"; \
|
TS_PARSER_DIR="/home/pentester/.tree-sitter/parsers"; \
|
||||||
@@ -164,12 +171,12 @@ RUN apt-get autoremove -y && \
|
|||||||
apt-get autoclean && \
|
apt-get autoclean && \
|
||||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
ENV PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"
|
ENV PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/venv/bin:$PATH"
|
||||||
ENV VIRTUAL_ENV="/app/.venv"
|
ENV VIRTUAL_ENV="/app/venv"
|
||||||
|
ENV POETRY_HOME="/opt/poetry"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG CAIDO_VERSION=0.56.0
|
|
||||||
RUN ARCH=$(uname -m) && \
|
RUN ARCH=$(uname -m) && \
|
||||||
if [ "$ARCH" = "x86_64" ]; then \
|
if [ "$ARCH" = "x86_64" ]; then \
|
||||||
CAIDO_ARCH="x86_64"; \
|
CAIDO_ARCH="x86_64"; \
|
||||||
@@ -178,29 +185,36 @@ RUN ARCH=$(uname -m) && \
|
|||||||
else \
|
else \
|
||||||
echo "Unsupported architecture: $ARCH" && exit 1; \
|
echo "Unsupported architecture: $ARCH" && exit 1; \
|
||||||
fi && \
|
fi && \
|
||||||
wget -O caido-cli.tar.gz "https://caido.download/releases/v${CAIDO_VERSION}/caido-cli-v${CAIDO_VERSION}-linux-${CAIDO_ARCH}.tar.gz" && \
|
wget -O caido-cli.tar.gz https://caido.download/releases/v0.48.0/caido-cli-v0.48.0-linux-${CAIDO_ARCH}.tar.gz && \
|
||||||
tar -xzf caido-cli.tar.gz && \
|
tar -xzf caido-cli.tar.gz && \
|
||||||
chmod +x caido-cli && \
|
chmod +x caido-cli && \
|
||||||
rm caido-cli.tar.gz && \
|
rm caido-cli.tar.gz && \
|
||||||
mv caido-cli /usr/local/bin/
|
mv caido-cli /usr/local/bin/
|
||||||
|
|
||||||
|
ENV STRIX_SANDBOX_MODE=true
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
|
ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
|
||||||
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||||
|
|
||||||
RUN mkdir -p /workspace && chown -R pentester:pentester /workspace /app
|
RUN mkdir -p /workspace && chown -R pentester:pentester /workspace /app
|
||||||
|
|
||||||
USER pentester
|
COPY pyproject.toml poetry.lock ./
|
||||||
RUN python3 -m venv /app/.venv && \
|
|
||||||
/app/.venv/bin/pip install --no-cache-dir caido-sdk-client && \
|
|
||||||
/app/.venv/bin/pip install --no-cache-dir -r /home/pentester/tools/jwt_tool/requirements.txt && \
|
|
||||||
printf '%s\n' \
|
|
||||||
'#!/bin/bash' \
|
|
||||||
'exec /app/.venv/bin/python /home/pentester/tools/jwt_tool/jwt_tool.py "$@"' \
|
|
||||||
> /home/pentester/.local/bin/jwt_tool && \
|
|
||||||
chmod +x /home/pentester/.local/bin/jwt_tool
|
|
||||||
|
|
||||||
COPY --chown=pentester:pentester strix/tools/proxy/caido_api.py /opt/strix-python/caido_api.py
|
USER pentester
|
||||||
ENV PYTHONPATH=/opt/strix-python
|
RUN poetry install --no-root --without dev --extras sandbox
|
||||||
|
RUN poetry run playwright install chromium
|
||||||
|
|
||||||
|
RUN /app/venv/bin/pip install -r /home/pentester/tools/jwt_tool/requirements.txt && \
|
||||||
|
ln -s /home/pentester/tools/jwt_tool/jwt_tool.py /home/pentester/.local/bin/jwt_tool
|
||||||
|
|
||||||
|
RUN echo "# Sandbox Environment" > README.md
|
||||||
|
|
||||||
|
COPY strix/__init__.py strix/
|
||||||
|
COPY strix/config/ /app/strix/config/
|
||||||
|
COPY strix/utils/ /app/strix/utils/
|
||||||
|
COPY strix/telemetry/ /app/strix/telemetry/
|
||||||
|
COPY strix/runtime/tool_server.py strix/runtime/__init__.py strix/runtime/runtime.py /app/strix/runtime/
|
||||||
|
COPY strix/tools/ /app/strix/tools/
|
||||||
|
|
||||||
RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.bashrc && \
|
RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.bashrc && \
|
||||||
echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.profile
|
echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.profile
|
||||||
|
|||||||
@@ -47,7 +47,68 @@ fi
|
|||||||
|
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
echo "Caido is up — host bootstraps the guest token + project via the Python SDK."
|
echo "Fetching API token..."
|
||||||
|
TOKEN=""
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
RESPONSE=$(curl -sL -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query":"mutation LoginAsGuest { loginAsGuest { token { accessToken } } }"}' \
|
||||||
|
http://localhost:${CAIDO_PORT}/graphql)
|
||||||
|
|
||||||
|
TOKEN=$(echo "$RESPONSE" | jq -r '.data.loginAsGuest.token.accessToken // empty')
|
||||||
|
|
||||||
|
if [ -n "$TOKEN" ] && [ "$TOKEN" != "null" ]; then
|
||||||
|
echo "Successfully obtained API token (attempt $attempt)."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Token fetch attempt $attempt failed: $RESPONSE"
|
||||||
|
sleep $((attempt * 2))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ] || [ "$TOKEN" == "null" ]; then
|
||||||
|
echo "ERROR: Failed to get API token from Caido after 5 attempts."
|
||||||
|
echo "=== Caido log ==="
|
||||||
|
cat "$CAIDO_LOG" 2>/dev/null || echo "(no log available)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export CAIDO_API_TOKEN=$TOKEN
|
||||||
|
echo "Caido API token has been set."
|
||||||
|
|
||||||
|
echo "Creating a new Caido project..."
|
||||||
|
CREATE_PROJECT_RESPONSE=$(curl -sL -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"query":"mutation CreateProject { createProject(input: {name: \"sandbox\", temporary: true}) { project { id } } }"}' \
|
||||||
|
http://localhost:${CAIDO_PORT}/graphql)
|
||||||
|
|
||||||
|
PROJECT_ID=$(echo $CREATE_PROJECT_RESPONSE | jq -r '.data.createProject.project.id')
|
||||||
|
|
||||||
|
if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" == "null" ]; then
|
||||||
|
echo "Failed to create Caido project."
|
||||||
|
echo "Response: $CREATE_PROJECT_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Caido project created with ID: $PROJECT_ID"
|
||||||
|
|
||||||
|
echo "Selecting Caido project..."
|
||||||
|
SELECT_RESPONSE=$(curl -sL -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"query":"mutation SelectProject { selectProject(id: \"'$PROJECT_ID'\") { currentProject { project { id } } } }"}' \
|
||||||
|
http://localhost:${CAIDO_PORT}/graphql)
|
||||||
|
|
||||||
|
SELECTED_ID=$(echo $SELECT_RESPONSE | jq -r '.data.selectProject.currentProject.project.id')
|
||||||
|
|
||||||
|
if [ "$SELECTED_ID" != "$PROJECT_ID" ]; then
|
||||||
|
echo "Failed to select Caido project."
|
||||||
|
echo "Response: $SELECT_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Caido project selected successfully."
|
||||||
|
|
||||||
echo "Configuring system-wide proxy settings..."
|
echo "Configuring system-wide proxy settings..."
|
||||||
|
|
||||||
@@ -57,9 +118,9 @@ export https_proxy=http://127.0.0.1:${CAIDO_PORT}
|
|||||||
export HTTP_PROXY=http://127.0.0.1:${CAIDO_PORT}
|
export HTTP_PROXY=http://127.0.0.1:${CAIDO_PORT}
|
||||||
export HTTPS_PROXY=http://127.0.0.1:${CAIDO_PORT}
|
export HTTPS_PROXY=http://127.0.0.1:${CAIDO_PORT}
|
||||||
export ALL_PROXY=http://127.0.0.1:${CAIDO_PORT}
|
export ALL_PROXY=http://127.0.0.1:${CAIDO_PORT}
|
||||||
export NO_PROXY=localhost,127.0.0.1
|
|
||||||
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
|
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
|
||||||
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||||
|
export CAIDO_API_TOKEN=${TOKEN}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat << EOF | sudo tee /etc/environment
|
cat << EOF | sudo tee /etc/environment
|
||||||
@@ -68,7 +129,7 @@ https_proxy=http://127.0.0.1:${CAIDO_PORT}
|
|||||||
HTTP_PROXY=http://127.0.0.1:${CAIDO_PORT}
|
HTTP_PROXY=http://127.0.0.1:${CAIDO_PORT}
|
||||||
HTTPS_PROXY=http://127.0.0.1:${CAIDO_PORT}
|
HTTPS_PROXY=http://127.0.0.1:${CAIDO_PORT}
|
||||||
ALL_PROXY=http://127.0.0.1:${CAIDO_PORT}
|
ALL_PROXY=http://127.0.0.1:${CAIDO_PORT}
|
||||||
NO_PROXY=localhost,127.0.0.1
|
CAIDO_API_TOKEN=${TOKEN}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat << EOF | sudo tee /etc/wgetrc
|
cat << EOF | sudo tee /etc/wgetrc
|
||||||
@@ -90,7 +151,34 @@ sudo -u pentester certutil -N -d sql:/home/pentester/.pki/nssdb --empty-password
|
|||||||
sudo -u pentester certutil -A -n "Testing Root CA" -t "C,," -i /app/certs/ca.crt -d sql:/home/pentester/.pki/nssdb
|
sudo -u pentester certutil -A -n "Testing Root CA" -t "C,," -i /app/certs/ca.crt -d sql:/home/pentester/.pki/nssdb
|
||||||
echo "✅ CA added to browser trust store"
|
echo "✅ CA added to browser trust store"
|
||||||
|
|
||||||
mkdir -p /workspace/.agent-browser-screenshots
|
echo "Starting tool server..."
|
||||||
|
cd /app
|
||||||
|
export PYTHONPATH=/app
|
||||||
|
export STRIX_SANDBOX_MODE=true
|
||||||
|
export POETRY_VIRTUALENVS_CREATE=false
|
||||||
|
export TOOL_SERVER_TIMEOUT="${STRIX_SANDBOX_EXECUTION_TIMEOUT:-120}"
|
||||||
|
TOOL_SERVER_LOG="/tmp/tool_server.log"
|
||||||
|
|
||||||
|
sudo -E -u pentester \
|
||||||
|
poetry run python -m strix.runtime.tool_server \
|
||||||
|
--token="$TOOL_SERVER_TOKEN" \
|
||||||
|
--host=0.0.0.0 \
|
||||||
|
--port="$TOOL_SERVER_PORT" \
|
||||||
|
--timeout="$TOOL_SERVER_TIMEOUT" > "$TOOL_SERVER_LOG" 2>&1 &
|
||||||
|
|
||||||
|
for i in {1..10}; do
|
||||||
|
if curl -s "http://127.0.0.1:$TOOL_SERVER_PORT/health" | grep -q '"status":"healthy"'; then
|
||||||
|
echo "✅ Tool server healthy on port $TOOL_SERVER_PORT"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ $i -eq 10 ]; then
|
||||||
|
echo "ERROR: Tool server failed to become healthy"
|
||||||
|
echo "=== Tool server log ==="
|
||||||
|
cat "$TOOL_SERVER_LOG" 2>/dev/null || echo "(no log)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
echo "✅ Container ready"
|
echo "✅ Container ready"
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Configuration"
|
|||||||
description: "Environment variables for Strix"
|
description: "Environment variables for Strix"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Configure Strix using environment variables or a config file.
|
Configure Strix using environment variables or a config file.
|
||||||
|
|
||||||
## LLM Configuration
|
## LLM Configuration
|
||||||
@@ -45,8 +41,20 @@ Configure Strix using environment variables or a config file.
|
|||||||
API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
|
API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="STRIX_DISABLE_BROWSER" default="false" type="boolean">
|
||||||
|
Disable browser automation tools.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
<ParamField path="STRIX_TELEMETRY" default="1" type="string">
|
<ParamField path="STRIX_TELEMETRY" default="1" type="string">
|
||||||
Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL).
|
Global telemetry default toggle. Set to `0`, `false`, `no`, or `off` to disable both PostHog and OTEL unless overridden by per-channel flags below.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="STRIX_OTEL_TELEMETRY" type="string">
|
||||||
|
Enable/disable OpenTelemetry run observability independently. When unset, falls back to `STRIX_TELEMETRY`.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="STRIX_POSTHOG_TELEMETRY" type="string">
|
||||||
|
Enable/disable PostHog product telemetry independently. When unset, falls back to `STRIX_TELEMETRY`.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
<ParamField path="TRACELOOP_BASE_URL" type="string">
|
<ParamField path="TRACELOOP_BASE_URL" type="string">
|
||||||
@@ -71,7 +79,7 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
|
|||||||
|
|
||||||
## Docker Configuration
|
## Docker Configuration
|
||||||
|
|
||||||
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:1.0.0" type="string">
|
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:0.1.13" type="string">
|
||||||
Docker image to use for the sandbox container.
|
Docker image to use for the sandbox container.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
@@ -83,10 +91,6 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
|
|||||||
Runtime backend for the sandbox environment.
|
Runtime backend for the sandbox environment.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
<ParamField path="STRIX_MAX_LOCAL_COPY_MB" default="1024" type="integer">
|
|
||||||
Maximum size (in MB) of a local directory target that Strix will copy into the sandbox file-by-file. Larger targets exit early with a suggestion to use `--mount` instead. Set to `0` to disable the check.
|
|
||||||
</ParamField>
|
|
||||||
|
|
||||||
## Sandbox Configuration
|
## Sandbox Configuration
|
||||||
|
|
||||||
<ParamField path="STRIX_SANDBOX_EXECUTION_TIMEOUT" default="120" type="integer">
|
<ParamField path="STRIX_SANDBOX_EXECUTION_TIMEOUT" default="120" type="integer">
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Skills"
|
|||||||
description: "Specialized knowledge packages that enhance agent capabilities"
|
description: "Specialized knowledge packages that enhance agent capabilities"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Skills are structured knowledge packages that give Strix agents deep expertise in specific vulnerability types, technologies, and testing methodologies.
|
Skills are structured knowledge packages that give Strix agents deep expertise in specific vulnerability types, technologies, and testing methodologies.
|
||||||
|
|
||||||
## The Idea
|
## The Idea
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Introduction"
|
|||||||
description: "Managed security testing without local setup"
|
description: "Managed security testing without local setup"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Skip the setup. Run Strix in the cloud at [app.strix.ai](https://app.strix.ai).
|
Skip the setup. Run Strix in the cloud at [app.strix.ai](https://app.strix.ai).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|||||||
@@ -3,17 +3,13 @@ title: "Contributing"
|
|||||||
description: "Contribute to Strix development"
|
description: "Contribute to Strix development"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Python 3.12+
|
- Python 3.12+
|
||||||
- Docker (running)
|
- Docker (running)
|
||||||
- [uv](https://docs.astral.sh/uv/)
|
- Poetry
|
||||||
- Git
|
- Git
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
@@ -30,8 +26,8 @@ import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|||||||
make setup-dev
|
make setup-dev
|
||||||
|
|
||||||
# or manually:
|
# or manually:
|
||||||
uv sync
|
poetry install --with=dev
|
||||||
uv run pre-commit install
|
poetry run pre-commit install
|
||||||
```
|
```
|
||||||
</Step>
|
</Step>
|
||||||
<Step title="Configure LLM">
|
<Step title="Configure LLM">
|
||||||
@@ -42,7 +38,7 @@ import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|||||||
</Step>
|
</Step>
|
||||||
<Step title="Run Strix">
|
<Step title="Run Strix">
|
||||||
```bash
|
```bash
|
||||||
uv run strix --target https://example.com
|
poetry run strix --target https://example.com
|
||||||
```
|
```
|
||||||
</Step>
|
</Step>
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|||||||
@@ -38,7 +38,6 @@
|
|||||||
"llm-providers/vertex",
|
"llm-providers/vertex",
|
||||||
"llm-providers/bedrock",
|
"llm-providers/bedrock",
|
||||||
"llm-providers/azure",
|
"llm-providers/azure",
|
||||||
"llm-providers/novita",
|
|
||||||
"llm-providers/local"
|
"llm-providers/local"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Introduction"
|
|||||||
description: "Open-source AI hackers to secure your apps"
|
description: "Open-source AI hackers to secure your apps"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Strix are autonomous AI agents that act like real hackers—they run your code dynamically, find vulnerabilities, and validate them with proof-of-concepts. Built for developers and security teams who need fast, accurate security testing without the overhead of manual pentesting or the false positives of static analysis tools.
|
Strix are autonomous AI agents that act like real hackers—they run your code dynamically, find vulnerabilities, and validate them with proof-of-concepts. Built for developers and security teams who need fast, accurate security testing without the overhead of manual pentesting or the false positives of static analysis tools.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "CI/CD Integration"
|
|||||||
description: "Run Strix in any CI/CD pipeline"
|
description: "Run Strix in any CI/CD pipeline"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Strix runs in headless mode for automated pipelines.
|
Strix runs in headless mode for automated pipelines.
|
||||||
|
|
||||||
## Headless Mode
|
## Headless Mode
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "GitHub Actions"
|
|||||||
description: "Run Strix security scans on every pull request"
|
description: "Run Strix security scans on every pull request"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Integrate Strix into your GitHub workflow to catch vulnerabilities before they reach production.
|
Integrate Strix into your GitHub workflow to catch vulnerabilities before they reach production.
|
||||||
|
|
||||||
## Basic Workflow
|
## Basic Workflow
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Anthropic"
|
|||||||
description: "Configure Strix with Claude models"
|
description: "Configure Strix with Claude models"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Azure OpenAI"
|
|||||||
description: "Configure Strix with OpenAI models via Azure"
|
description: "Configure Strix with OpenAI models via Azure"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "AWS Bedrock"
|
|||||||
description: "Configure Strix with models via AWS Bedrock"
|
description: "Configure Strix with models via AWS Bedrock"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Local Models"
|
|||||||
description: "Run Strix with self-hosted LLMs for privacy and air-gapped testing"
|
description: "Run Strix with self-hosted LLMs for privacy and air-gapped testing"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Running Strix with local models allows for completely offline, privacy-first security assessments. Data never leaves your machine, making this ideal for sensitive internal networks or air-gapped environments.
|
Running Strix with local models allows for completely offline, privacy-first security assessments. Data never leaves your machine, making this ideal for sensitive internal networks or air-gapped environments.
|
||||||
|
|
||||||
## Privacy vs Performance
|
## Privacy vs Performance
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Novita AI"
|
|
||||||
description: "Configure Strix with Novita AI models"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
[Novita AI](https://novita.ai) provides fast, cost-efficient inference for open-source models via an OpenAI-compatible API.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export STRIX_LLM="openai/moonshotai/kimi-k2.5"
|
|
||||||
export LLM_API_KEY="your-novita-api-key"
|
|
||||||
export LLM_API_BASE="https://api.novita.ai/openai"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Models
|
|
||||||
|
|
||||||
| Model | Configuration |
|
|
||||||
|-------|---------------|
|
|
||||||
| Kimi K2.5 | `openai/moonshotai/kimi-k2.5` |
|
|
||||||
| GLM-5 | `openai/zai-org/glm-5` |
|
|
||||||
| MiniMax M2.5 | `openai/minimax/minimax-m2.5` |
|
|
||||||
|
|
||||||
## Get API Key
|
|
||||||
|
|
||||||
1. Sign up at [novita.ai](https://novita.ai)
|
|
||||||
2. Navigate to **API Keys** in your dashboard
|
|
||||||
3. Create a new key and copy it
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
- **Cost-efficient** — Competitive pricing with per-token billing
|
|
||||||
- **OpenAI-compatible** — Drop-in replacement using `LLM_API_BASE`
|
|
||||||
- **Large context** — Models support up to 262k token context windows
|
|
||||||
- **Function calling** — All listed models support tool/function calling
|
|
||||||
@@ -3,10 +3,6 @@ title: "OpenAI"
|
|||||||
description: "Configure Strix with OpenAI models"
|
description: "Configure Strix with OpenAI models"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "OpenRouter"
|
|||||||
description: "Configure Strix with models via OpenRouter"
|
description: "Configure Strix with models via OpenRouter"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
[OpenRouter](https://openrouter.ai) provides access to 100+ models from multiple providers through a single API.
|
[OpenRouter](https://openrouter.ai) provides access to 100+ models from multiple providers through a single API.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Overview"
|
|||||||
description: "Configure your AI model for Strix"
|
description: "Configure your AI model for Strix"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Strix uses [LiteLLM](https://docs.litellm.ai/docs/providers) for model compatibility, supporting 100+ LLM providers.
|
Strix uses [LiteLLM](https://docs.litellm.ai/docs/providers) for model compatibility, supporting 100+ LLM providers.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Google Vertex AI"
|
|||||||
description: "Configure Strix with Gemini models via Google Cloud"
|
description: "Configure Strix with Gemini models via Google Cloud"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Vertex AI requires the Google Cloud dependency. Install Strix with the vertex extra:
|
Vertex AI requires the Google Cloud dependency. Install Strix with the vertex extra:
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Quick Start"
|
|||||||
description: "Install Strix and run your first security scan"
|
description: "Install Strix and run your first security scan"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Docker (running)
|
- Docker (running)
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
export const ScarfPixel = () => (
|
|
||||||
<img
|
|
||||||
referrerPolicy="no-referrer-when-downgrade"
|
|
||||||
src="https://static.scarf.sh/a.png?x-pxid=a0ba15dd-a205-4a54-95d6-7814e9ae6b61"
|
|
||||||
alt=""
|
|
||||||
width="1"
|
|
||||||
height="1"
|
|
||||||
style={{ position: "absolute", width: 0, height: 0, opacity: 0, pointerEvents: "none" }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
agentic
|
|
||||||
Caido
|
|
||||||
deobfuscation
|
|
||||||
deserialization
|
|
||||||
Devstral
|
|
||||||
[Dd]ocstrings
|
|
||||||
exfiltration
|
|
||||||
failover
|
|
||||||
ffuf
|
|
||||||
Firestore
|
|
||||||
frontmatter
|
|
||||||
fuzzer
|
|
||||||
gcloud
|
|
||||||
hardcoded
|
|
||||||
Kimi
|
|
||||||
Langfuse
|
|
||||||
LLMs?
|
|
||||||
[Mm]isconfigurations?
|
|
||||||
Novita
|
|
||||||
Ollama
|
|
||||||
pentest(ers|ing)?
|
|
||||||
pipx
|
|
||||||
pull_request
|
|
||||||
Pydantic
|
|
||||||
spidering
|
|
||||||
SQLi
|
|
||||||
[Ss]trix
|
|
||||||
Supabase
|
|
||||||
traceback
|
|
||||||
UIs
|
|
||||||
untrusted
|
|
||||||
uv
|
|
||||||
vulns
|
|
||||||
[Ww]ordlist
|
|
||||||
@@ -3,10 +3,6 @@ title: "Browser"
|
|||||||
description: "Playwright-powered Chrome for web application testing"
|
description: "Playwright-powered Chrome for web application testing"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Strix uses a headless Chrome browser via Playwright to interact with web applications exactly like a real user would.
|
Strix uses a headless Chrome browser via Playwright to interact with web applications exactly like a real user would.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Agent Tools"
|
|||||||
description: "How Strix agents interact with targets"
|
description: "How Strix agents interact with targets"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Strix agents use specialized tools to test your applications like a real penetration tester would.
|
Strix agents use specialized tools to test your applications like a real penetration tester would.
|
||||||
|
|
||||||
## Core Tools
|
## Core Tools
|
||||||
|
|||||||
+27
-55
@@ -3,10 +3,6 @@ title: "HTTP Proxy"
|
|||||||
description: "Caido-powered proxy for request interception and replay"
|
description: "Caido-powered proxy for request interception and replay"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Strix includes [Caido](https://caido.io), a modern HTTP proxy built for security testing. All browser traffic flows through Caido, giving the agent full control over requests and responses.
|
Strix includes [Caido](https://caido.io), a modern HTTP proxy built for security testing. All browser traffic flows through Caido, giving the agent full control over requests and responses.
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
@@ -34,33 +30,23 @@ The agent can take any captured request and replay it with modifications:
|
|||||||
|
|
||||||
## Python Integration
|
## Python Integration
|
||||||
|
|
||||||
Proxy helpers are available to sandbox Python scripts through the image-baked `caido_api` module. This enables powerful scripted security testing:
|
All proxy functions are automatically available in Python sessions. This enables powerful scripted security testing:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import asyncio
|
# List recent POST requests
|
||||||
|
post_requests = list_requests(
|
||||||
|
httpql_filter='req.method.eq:"POST"',
|
||||||
|
page_size=20
|
||||||
|
)
|
||||||
|
|
||||||
from caido_api import list_requests, repeat_request, view_request
|
# View a specific request
|
||||||
|
request_details = view_request("req_123", part="request")
|
||||||
|
|
||||||
|
# Replay with modified payload
|
||||||
async def main():
|
response = repeat_request("req_123", {
|
||||||
# List recent POST requests
|
"body": '{"user_id": "admin"}'
|
||||||
post_requests = await list_requests(
|
})
|
||||||
httpql_filter='req.method.eq:"POST"',
|
print(f"Status: {response['status_code']}")
|
||||||
first=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
# View a specific request
|
|
||||||
request_details = await view_request("req_123", part="request")
|
|
||||||
|
|
||||||
# Replay with modified payload
|
|
||||||
response = await repeat_request(
|
|
||||||
"req_123",
|
|
||||||
modifications={"body": '{"user_id": "admin"}'},
|
|
||||||
)
|
|
||||||
print(response["status"], request_details is not None, len(post_requests.edges))
|
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Functions
|
### Available Functions
|
||||||
@@ -70,42 +56,28 @@ asyncio.run(main())
|
|||||||
| `list_requests()` | Query captured traffic with HTTPQL filters |
|
| `list_requests()` | Query captured traffic with HTTPQL filters |
|
||||||
| `view_request()` | Get full request/response details |
|
| `view_request()` | Get full request/response details |
|
||||||
| `repeat_request()` | Replay a request with modifications |
|
| `repeat_request()` | Replay a request with modifications |
|
||||||
| `list_sitemap()` | Browse the request-tree view of discovered surface |
|
| `send_request()` | Send a new HTTP request |
|
||||||
| `view_sitemap_entry()` | Inspect one sitemap entry + its related requests |
|
|
||||||
| `scope_rules()` | Manage proxy scope (allowlist/denylist) |
|
| `scope_rules()` | Manage proxy scope (allowlist/denylist) |
|
||||||
|
| `list_sitemap()` | View discovered endpoints |
|
||||||
For one-off arbitrary requests, use shell tooling like `curl` — the
|
| `view_sitemap_entry()` | Get details for a sitemap entry |
|
||||||
sandbox's `HTTP_PROXY` env routes the traffic through Caido
|
|
||||||
automatically, so it lands in `list_requests` and can be replayed via
|
|
||||||
`repeat_request`.
|
|
||||||
|
|
||||||
### Example: Automated IDOR Testing
|
### Example: Automated IDOR Testing
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import asyncio
|
|
||||||
|
|
||||||
# Get all requests to user endpoints
|
# Get all requests to user endpoints
|
||||||
from caido_api import list_requests, repeat_request
|
user_requests = list_requests(
|
||||||
|
httpql_filter='req.path.cont:"/users/"'
|
||||||
|
)
|
||||||
|
|
||||||
|
for req in user_requests.get('requests', []):
|
||||||
|
# Try accessing with different user IDs
|
||||||
|
for test_id in ['1', '2', 'admin', '../admin']:
|
||||||
|
response = repeat_request(req['id'], {
|
||||||
|
'url': req['path'].replace('/users/1', f'/users/{test_id}')
|
||||||
|
})
|
||||||
|
|
||||||
async def main():
|
if response['status_code'] == 200:
|
||||||
user_requests = await list_requests(httpql_filter='req.path.cont:"/users/"')
|
print(f"Potential IDOR: {test_id} returned 200")
|
||||||
|
|
||||||
for edge in user_requests.edges:
|
|
||||||
req = edge.node.request
|
|
||||||
scheme = "https" if req.is_tls else "http"
|
|
||||||
for test_id in ["1", "2", "admin", "../admin"]:
|
|
||||||
url = f"{scheme}://{req.host}{req.path.replace('/users/1', f'/users/{test_id}')}"
|
|
||||||
response = await repeat_request(
|
|
||||||
req.id,
|
|
||||||
modifications={"url": url},
|
|
||||||
)
|
|
||||||
print(req.id, test_id, response["status"])
|
|
||||||
if response["status"] == "DONE":
|
|
||||||
print(f"Replay completed for candidate {test_id}")
|
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Human-in-the-Loop
|
## Human-in-the-Loop
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Sandbox Tools"
|
|||||||
description: "Pre-installed security tools in the Strix container"
|
description: "Pre-installed security tools in the Strix container"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Strix runs inside a Kali Linux-based Docker container with a comprehensive set of security tools pre-installed. The agent can use any of these tools through the [terminal](/tools/terminal).
|
Strix runs inside a Kali Linux-based Docker container with a comprehensive set of security tools pre-installed. The agent can use any of these tools through the [terminal](/tools/terminal).
|
||||||
|
|
||||||
## Reconnaissance
|
## Reconnaissance
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Terminal"
|
|||||||
description: "Bash shell for running commands and security tools"
|
description: "Bash shell for running commands and security tools"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Strix has access to a persistent bash terminal running inside the Docker sandbox. This gives the agent access to all [pre-installed security tools](/tools/sandbox).
|
Strix has access to a persistent bash terminal running inside the Docker sandbox. This gives the agent access to all [pre-installed security tools](/tools/sandbox).
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "CLI Reference"
|
|||||||
description: "Command-line options for Strix"
|
description: "Command-line options for Strix"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
## Basic Usage
|
## Basic Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -19,20 +15,6 @@ strix --target <target> [options]
|
|||||||
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times.
|
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
<ParamField path="--mount" type="string">
|
|
||||||
Bind-mount a local directory into the sandbox (read-only) instead of copying it in file-by-file. Use this for large repositories that are too big to stream into the container. Can be specified multiple times.
|
|
||||||
|
|
||||||
Strix copies local `--target` directories into the sandbox one file at a time, which stalls on very large trees. When a local target exceeds the copy limit (see `STRIX_MAX_LOCAL_COPY_MB`, default 1024 MB) Strix exits early and asks you to re-run with `--mount`.
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
The mount is read-only to protect your source from accidental modification. This is not a hard security boundary: a root process inside the container can remount it writable, so treat `--mount` as "scan my own code", not as isolation from untrusted code.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
The size pre-flight only covers local directory targets. Remote repositories (cloned at scan time) are not size-checked.
|
|
||||||
</Note>
|
|
||||||
</ParamField>
|
|
||||||
|
|
||||||
<ParamField path="--instruction" type="string">
|
<ParamField path="--instruction" type="string">
|
||||||
Custom instructions for the scan. Use for credentials, focus areas, or specific testing approaches.
|
Custom instructions for the scan. Use for credentials, focus areas, or specific testing approaches.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
@@ -61,24 +43,6 @@ strix --target <target> [options]
|
|||||||
Path to a custom config file (JSON) to use instead of `~/.strix/cli-config.json`.
|
Path to a custom config file (JSON) to use instead of `~/.strix/cli-config.json`.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
<ParamField path="--max-budget-usd" type="number">
|
|
||||||
Maximum LLM spend in USD for the whole scan, counted cumulatively across the
|
|
||||||
root agent and every child agent. The budget is checked after each model
|
|
||||||
response; once the running cost reaches the threshold, the scan stops cleanly
|
|
||||||
with a `stopped` status (not a failure) and the sandbox is torn down.
|
|
||||||
|
|
||||||
Must be greater than `0`. Omit the flag for no limit.
|
|
||||||
|
|
||||||
**Limitations**
|
|
||||||
|
|
||||||
- The check fires *after* a response is returned, so the final spend can
|
|
||||||
slightly overshoot the limit by any calls already in flight when the
|
|
||||||
threshold is crossed (most relevant with several child agents running
|
|
||||||
concurrently).
|
|
||||||
- Cost is a best-effort estimate derived from token usage and model pricing;
|
|
||||||
providers that do not expose priced usage may under-count.
|
|
||||||
</ParamField>
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -99,9 +63,6 @@ strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
|
|||||||
|
|
||||||
# Multi-target white-box testing
|
# Multi-target white-box testing
|
||||||
strix -t https://github.com/org/app -t https://staging.example.com
|
strix -t https://github.com/org/app -t https://staging.example.com
|
||||||
|
|
||||||
# Large local repository — bind-mount instead of copying it in
|
|
||||||
strix --mount ./huge-monorepo
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Exit Codes
|
## Exit Codes
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Custom Instructions"
|
|||||||
description: "Guide Strix with custom testing instructions"
|
description: "Guide Strix with custom testing instructions"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Use instructions to provide context, credentials, or focus areas for your scan.
|
Use instructions to provide context, credentials, or focus areas for your scan.
|
||||||
|
|
||||||
## Inline Instructions
|
## Inline Instructions
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ title: "Scan Modes"
|
|||||||
description: "Choose the right scan depth for your use case"
|
description: "Choose the right scan depth for your use case"
|
||||||
---
|
---
|
||||||
|
|
||||||
import { ScarfPixel } from "/snippets/scarf-pixel.mdx";
|
|
||||||
|
|
||||||
<ScarfPixel />
|
|
||||||
|
|
||||||
Strix offers three scan modes to balance speed and thoroughness.
|
Strix offers three scan modes to balance speed and thoroughness.
|
||||||
|
|
||||||
## Quick
|
## Quick
|
||||||
|
|||||||
Generated
+8794
File diff suppressed because it is too large
Load Diff
+143
-84
@@ -1,13 +1,10 @@
|
|||||||
[project]
|
[tool.poetry]
|
||||||
name = "strix-agent"
|
name = "strix-agent"
|
||||||
version = "1.0.4"
|
version = "0.8.3"
|
||||||
description = "Open-source AI Hackers for your apps"
|
description = "Open-source AI Hackers for your apps"
|
||||||
|
authors = ["Strix <hi@usestrix.com>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
requires-python = ">=3.12"
|
|
||||||
authors = [
|
|
||||||
{ name = "Strix", email = "hi@usestrix.com" },
|
|
||||||
]
|
|
||||||
keywords = [
|
keywords = [
|
||||||
"cybersecurity",
|
"cybersecurity",
|
||||||
"security",
|
"security",
|
||||||
@@ -32,42 +29,81 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
"Programming Language :: Python :: 3.14",
|
"Programming Language :: Python :: 3.14",
|
||||||
]
|
]
|
||||||
dependencies = [
|
packages = [
|
||||||
"openai-agents[litellm]==0.14.6",
|
{ include = "strix", format = ["sdist", "wheel"] }
|
||||||
"pydantic>=2.11.3",
|
]
|
||||||
"pydantic-settings>=2.13.0",
|
include = [
|
||||||
"rich",
|
"LICENSE",
|
||||||
"docker>=7.1.0",
|
"README.md",
|
||||||
"textual>=6.0.0",
|
"strix/agents/**/*.jinja",
|
||||||
"requests>=2.32.0",
|
"strix/skills/**/*.md",
|
||||||
"cvss>=3.2",
|
"strix/**/*.xml",
|
||||||
"caido-sdk-client>=0.2.0",
|
"strix/**/*.tcss"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[tool.poetry.scripts]
|
||||||
strix = "strix.interface.main:main"
|
strix = "strix.interface.main:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[tool.poetry.dependencies]
|
||||||
dev = [
|
python = "^3.12"
|
||||||
"mypy>=1.16.0",
|
# Core CLI dependencies
|
||||||
"ruff>=0.11.13",
|
litellm = { version = "~1.81.1", extras = ["proxy"] }
|
||||||
"pyright>=1.1.401",
|
tenacity = "^9.0.0"
|
||||||
"bandit>=1.8.3",
|
pydantic = {extras = ["email"], version = "^2.11.3"}
|
||||||
"pre-commit>=4.2.0",
|
rich = "*"
|
||||||
"pyinstaller>=6.17.0; python_version >= '3.12' and python_version < '3.15'",
|
docker = "^7.1.0"
|
||||||
"pytest>=8.3",
|
textual = "^4.0.0"
|
||||||
"pytest-asyncio>=0.24",
|
xmltodict = "^0.13.0"
|
||||||
]
|
requests = "^2.32.0"
|
||||||
|
cvss = "^3.2"
|
||||||
|
traceloop-sdk = "^0.53.0"
|
||||||
|
opentelemetry-exporter-otlp-proto-http = "^1.40.0"
|
||||||
|
scrubadub = "^2.0.1"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
# Optional LLM provider dependencies
|
||||||
asyncio_mode = "auto"
|
google-cloud-aiplatform = { version = ">=1.38", optional = true }
|
||||||
|
|
||||||
|
# Sandbox-only dependencies (only needed inside Docker container)
|
||||||
|
fastapi = { version = "*", optional = true }
|
||||||
|
uvicorn = { version = "*", optional = true }
|
||||||
|
ipython = { version = "^9.3.0", optional = true }
|
||||||
|
openhands-aci = { version = "^0.3.0", optional = true }
|
||||||
|
playwright = { version = "^1.48.0", optional = true }
|
||||||
|
gql = { version = "^3.5.3", extras = ["requests"], optional = true }
|
||||||
|
pyte = { version = "^0.8.1", optional = true }
|
||||||
|
libtmux = { version = "^0.46.2", optional = true }
|
||||||
|
numpydoc = { version = "^1.8.0", optional = true }
|
||||||
|
defusedxml = "^0.7.1"
|
||||||
|
|
||||||
|
[tool.poetry.extras]
|
||||||
|
vertex = ["google-cloud-aiplatform"]
|
||||||
|
sandbox = ["fastapi", "uvicorn", "ipython", "openhands-aci", "playwright", "gql", "pyte", "libtmux", "numpydoc"]
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
# Type checking and static analysis
|
||||||
|
mypy = "^1.16.0"
|
||||||
|
ruff = "^0.11.13"
|
||||||
|
pyright = "^1.1.401"
|
||||||
|
pylint = "^3.3.7"
|
||||||
|
bandit = "^1.8.3"
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest = "^8.4.0"
|
||||||
|
pytest-asyncio = "^1.0.0"
|
||||||
|
pytest-cov = "^6.1.1"
|
||||||
|
pytest-mock = "^3.14.1"
|
||||||
|
|
||||||
|
# Development tools
|
||||||
|
pre-commit = "^4.2.0"
|
||||||
|
black = "^25.1.0"
|
||||||
|
isort = "^6.0.1"
|
||||||
|
|
||||||
|
# Build tools
|
||||||
|
pyinstaller = { version = "^6.17.0", python = ">=3.12,<3.15" }
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["poetry-core"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
|
||||||
packages = ["strix"]
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Type Checking Configuration
|
# Type Checking Configuration
|
||||||
@@ -98,20 +134,34 @@ pretty = true
|
|||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = [
|
module = [
|
||||||
"litellm.*",
|
"litellm.*",
|
||||||
|
"tenacity.*",
|
||||||
|
"numpydoc.*",
|
||||||
"rich.*",
|
"rich.*",
|
||||||
|
"IPython.*",
|
||||||
|
"openhands_aci.*",
|
||||||
|
"playwright.*",
|
||||||
|
"uvicorn.*",
|
||||||
"jinja2.*",
|
"jinja2.*",
|
||||||
"textual.*",
|
|
||||||
"cvss.*",
|
|
||||||
"docker.*",
|
|
||||||
"caido_sdk_client.*",
|
|
||||||
"pydantic_settings.*",
|
"pydantic_settings.*",
|
||||||
|
"jwt.*",
|
||||||
|
"httpx.*",
|
||||||
|
"gql.*",
|
||||||
|
"textual.*",
|
||||||
|
"pyte.*",
|
||||||
|
"libtmux.*",
|
||||||
|
"pytest.*",
|
||||||
|
"cvss.*",
|
||||||
|
"opentelemetry.*",
|
||||||
|
"scrubadub.*",
|
||||||
|
"traceloop.*",
|
||||||
]
|
]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
disable_error_code = ["import-untyped"]
|
|
||||||
|
|
||||||
|
# Relax strict rules for test files (pytest decorators are not fully typed)
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = ["tests.*"]
|
module = ["tests.*"]
|
||||||
disallow_untyped_decorators = false
|
disallow_untyped_decorators = false
|
||||||
|
disallow_untyped_defs = false
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Ruff Configuration (Fast Python Linter & Formatter)
|
# Ruff Configuration (Fast Python Linter & Formatter)
|
||||||
@@ -123,6 +173,7 @@ line-length = 100
|
|||||||
extend-exclude = [
|
extend-exclude = [
|
||||||
".git",
|
".git",
|
||||||
".mypy_cache",
|
".mypy_cache",
|
||||||
|
".pytest_cache",
|
||||||
".ruff_cache",
|
".ruff_cache",
|
||||||
"__pycache__",
|
"__pycache__",
|
||||||
"build",
|
"build",
|
||||||
@@ -158,6 +209,7 @@ select = [
|
|||||||
"PIE", # flake8-pie
|
"PIE", # flake8-pie
|
||||||
"T20", # flake8-print
|
"T20", # flake8-print
|
||||||
"PYI", # flake8-pyi
|
"PYI", # flake8-pyi
|
||||||
|
"PT", # flake8-pytest-style
|
||||||
"Q", # flake8-quotes
|
"Q", # flake8-quotes
|
||||||
"RSE", # flake8-raise
|
"RSE", # flake8-raise
|
||||||
"RET", # flake8-return
|
"RET", # flake8-return
|
||||||
@@ -195,56 +247,21 @@ ignore = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
# Lazy imports inside functions to avoid circular dependency with
|
"tests/**/*.py" = [
|
||||||
# strix.telemetry / strix.report.dedupe / cvss.
|
"S106", # Possible hardcoded password
|
||||||
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
|
"S108", # Possible insecure usage of temporary file/directory
|
||||||
"strix/tools/finish/tool.py" = ["PLC0415", "TC002"]
|
"ARG001", # Unused function argument
|
||||||
"strix/tools/reporting/tool.py" = ["PLC0415", "TC002"]
|
"PLR2004", # Magic value used in comparison
|
||||||
|
]
|
||||||
"strix/tools/**/*.py" = [
|
"strix/tools/**/*.py" = [
|
||||||
"ARG001", # Unused function argument (tools may have unused args for interface consistency)
|
"ARG001", # Unused function argument (tools may have unused args for interface consistency)
|
||||||
]
|
]
|
||||||
# Custom Docker subclass duplicates parent body; some imports are for annotations.
|
|
||||||
# Backend factories import their backend's deps lazily so deployments
|
|
||||||
# that pick a different backend don't need every backend's libs installed.
|
|
||||||
"strix/runtime/backends.py" = ["PLC0415"]
|
|
||||||
"strix/runtime/docker_client.py" = [
|
|
||||||
"TC002", # Manifest, Container imported for annotations
|
|
||||||
"TC003", # uuid imported for annotation
|
|
||||||
]
|
|
||||||
# SDK function-tool wrappers: the SDK calls get_type_hints() at registration
|
|
||||||
# time to derive the JSON schema, which evaluates annotations at runtime —
|
|
||||||
# so RunContextWrapper / Tool / TResponseInputItem must be imported eagerly,
|
|
||||||
# not under TYPE_CHECKING.
|
|
||||||
"strix/tools/todo/tools.py" = ["TC002"]
|
|
||||||
"strix/tools/thinking/tool.py" = ["TC002"]
|
|
||||||
"strix/tools/web_search/tool.py" = ["TC002"]
|
|
||||||
"strix/tools/proxy/tools.py" = ["TC002", "PLR0911"]
|
|
||||||
"strix/tools/agents_graph/tools.py" = ["TC002"]
|
|
||||||
"strix/agents/factory.py" = ["TC002"]
|
|
||||||
# Entry point: ``Path`` is used at runtime by the typing of the
|
|
||||||
# session_manager call; importing under TYPE_CHECKING would defer
|
|
||||||
# resolution past where mypy needs it.
|
|
||||||
"strix/core/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"]
|
|
||||||
# ReportState carries scan artifact/report fields and
|
|
||||||
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
|
|
||||||
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"]
|
|
||||||
"strix/report/usage.py" = ["PLC0415"]
|
|
||||||
"strix/config/models.py" = ["PLC0415"]
|
|
||||||
# Interface utility branches per scope-mode / target-type combination;
|
|
||||||
# splitting would obscure the decision tree without simplifying it.
|
|
||||||
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
|
|
||||||
# CLI / TUI / main keep extensive lazy imports + broad exception
|
|
||||||
# swallows for resilience around terminal-rendering errors.
|
|
||||||
"strix/interface/cli.py" = ["BLE001", "PLC0415"]
|
|
||||||
"strix/interface/tui/app.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915", "SIM105"]
|
|
||||||
"strix/interface/main.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"]
|
|
||||||
"strix/interface/tui/renderers/agent_message_renderer.py" = ["PLC0415"]
|
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
force-single-line = false
|
force-single-line = false
|
||||||
lines-after-imports = 2
|
lines-after-imports = 2
|
||||||
known-first-party = ["strix"]
|
known-first-party = ["strix"]
|
||||||
known-third-party = ["pydantic"]
|
known-third-party = ["fastapi", "pydantic"]
|
||||||
|
|
||||||
[tool.ruff.lint.pylint]
|
[tool.ruff.lint.pylint]
|
||||||
max-args = 8
|
max-args = 8
|
||||||
@@ -320,13 +337,55 @@ force_grid_wrap = 0
|
|||||||
use_parentheses = true
|
use_parentheses = true
|
||||||
ensure_newline_before_comments = true
|
ensure_newline_before_comments = true
|
||||||
known_first_party = ["strix"]
|
known_first_party = ["strix"]
|
||||||
known_third_party = ["pydantic", "litellm"]
|
known_third_party = ["fastapi", "pydantic", "litellm", "tenacity"]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Pytest Configuration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
minversion = "6.0"
|
||||||
|
addopts = [
|
||||||
|
"--strict-markers",
|
||||||
|
"--strict-config",
|
||||||
|
"--cov=strix",
|
||||||
|
"--cov-report=term-missing",
|
||||||
|
"--cov-report=html",
|
||||||
|
"--cov-report=xml",
|
||||||
|
]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py", "*_test.py"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["strix"]
|
||||||
|
omit = [
|
||||||
|
"*/tests/*",
|
||||||
|
"*/migrations/*",
|
||||||
|
"*/__pycache__/*"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"def __repr__",
|
||||||
|
"if self.debug:",
|
||||||
|
"if settings.DEBUG",
|
||||||
|
"raise AssertionError",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"if 0:",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"class .*\\bProtocol\\):",
|
||||||
|
"@(abc\\.)?abstractmethod",
|
||||||
|
]
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Bandit Configuration (Security Linting)
|
# Bandit Configuration (Security Linting)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
[tool.bandit]
|
[tool.bandit]
|
||||||
exclude_dirs = ["docs", "build", "dist"]
|
exclude_dirs = ["tests", "docs", "build", "dist"]
|
||||||
skips = ["B101", "B601", "B404", "B603", "B607"] # Skip assert, shell injection, subprocess import and partial path checks
|
skips = ["B101", "B601", "B404", "B603", "B607"] # Skip assert, shell injection, subprocess import and partial path checks
|
||||||
severity = "medium"
|
severity = "medium"
|
||||||
|
|||||||
+6
-6
@@ -33,23 +33,23 @@ echo -e "${YELLOW}Platform:${NC} $OS_NAME-$ARCH_NAME"
|
|||||||
|
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
if ! command -v uv &> /dev/null; then
|
if ! command -v poetry &> /dev/null; then
|
||||||
echo -e "${RED}Error: uv is not installed${NC}"
|
echo -e "${RED}Error: Poetry is not installed${NC}"
|
||||||
echo "Please install uv first: https://docs.astral.sh/uv/getting-started/installation/"
|
echo "Please install Poetry first: https://python-poetry.org/docs/#installation"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "\n${BLUE}Installing dependencies...${NC}"
|
echo -e "\n${BLUE}Installing dependencies...${NC}"
|
||||||
uv sync --frozen
|
poetry install --with dev
|
||||||
|
|
||||||
VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
VERSION=$(poetry version -s)
|
||||||
echo -e "${YELLOW}Version:${NC} $VERSION"
|
echo -e "${YELLOW}Version:${NC} $VERSION"
|
||||||
|
|
||||||
echo -e "\n${BLUE}Cleaning previous builds...${NC}"
|
echo -e "\n${BLUE}Cleaning previous builds...${NC}"
|
||||||
rm -rf build/ dist/
|
rm -rf build/ dist/
|
||||||
|
|
||||||
echo -e "\n${BLUE}Building binary with PyInstaller...${NC}"
|
echo -e "\n${BLUE}Building binary with PyInstaller...${NC}"
|
||||||
uv run pyinstaller strix.spec --noconfirm
|
poetry run pyinstaller strix.spec --noconfirm
|
||||||
|
|
||||||
RELEASE_DIR="dist/release"
|
RELEASE_DIR="dist/release"
|
||||||
mkdir -p "$RELEASE_DIR"
|
mkdir -p "$RELEASE_DIR"
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
|
|
||||||
IMAGE="strix-sandbox"
|
|
||||||
TAG="${1:-dev}"
|
|
||||||
|
|
||||||
echo "Building $IMAGE:$TAG ..."
|
|
||||||
docker build \
|
|
||||||
-f "$PROJECT_ROOT/containers/Dockerfile" \
|
|
||||||
-t "$IMAGE:$TAG" \
|
|
||||||
"$PROJECT_ROOT"
|
|
||||||
|
|
||||||
echo "Done: $IMAGE:$TAG"
|
|
||||||
+1
-1
@@ -4,7 +4,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
APP=strix
|
APP=strix
|
||||||
REPO="usestrix/strix"
|
REPO="usestrix/strix"
|
||||||
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.0.0"
|
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:0.1.13"
|
||||||
|
|
||||||
MUTED='\033[0;2m'
|
MUTED='\033[0;2m'
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
|
|||||||
+18
-49
@@ -32,8 +32,6 @@ datas += collect_data_files('tiktoken_ext')
|
|||||||
|
|
||||||
datas += collect_data_files('litellm')
|
datas += collect_data_files('litellm')
|
||||||
|
|
||||||
datas += collect_data_files('agents', includes=['**/*.md', '**/*.jinja', '**/*.json'])
|
|
||||||
|
|
||||||
hiddenimports = [
|
hiddenimports = [
|
||||||
# Core dependencies
|
# Core dependencies
|
||||||
'litellm',
|
'litellm',
|
||||||
@@ -118,58 +116,26 @@ hiddenimports = [
|
|||||||
'strix.interface.main',
|
'strix.interface.main',
|
||||||
'strix.interface.cli',
|
'strix.interface.cli',
|
||||||
'strix.interface.tui',
|
'strix.interface.tui',
|
||||||
'strix.interface.tui.app',
|
|
||||||
'strix.interface.tui.history',
|
|
||||||
'strix.interface.tui.live_view',
|
|
||||||
'strix.interface.tui.messages',
|
|
||||||
'strix.interface.tui.renderers',
|
|
||||||
'strix.interface.tui.renderers.agent_message_renderer',
|
|
||||||
'strix.interface.tui.renderers.agents_graph_renderer',
|
|
||||||
'strix.interface.tui.renderers.base_renderer',
|
|
||||||
'strix.interface.tui.renderers.finish_renderer',
|
|
||||||
'strix.interface.tui.renderers.notes_renderer',
|
|
||||||
'strix.interface.tui.renderers.proxy_renderer',
|
|
||||||
'strix.interface.tui.renderers.registry',
|
|
||||||
'strix.interface.tui.renderers.reporting_renderer',
|
|
||||||
'strix.interface.tui.renderers.thinking_renderer',
|
|
||||||
'strix.interface.tui.renderers.todo_renderer',
|
|
||||||
'strix.interface.tui.renderers.user_message_renderer',
|
|
||||||
'strix.interface.tui.renderers.web_search_renderer',
|
|
||||||
'strix.interface.utils',
|
'strix.interface.utils',
|
||||||
|
'strix.interface.tool_components',
|
||||||
'strix.agents',
|
'strix.agents',
|
||||||
'strix.agents.factory',
|
'strix.agents.base_agent',
|
||||||
'strix.agents.prompt',
|
'strix.agents.state',
|
||||||
'strix.config.models',
|
'strix.agents.StrixAgent',
|
||||||
'strix.core',
|
'strix.llm',
|
||||||
'strix.core.agents',
|
'strix.llm.llm',
|
||||||
'strix.core.execution',
|
'strix.llm.config',
|
||||||
'strix.core.inputs',
|
'strix.llm.utils',
|
||||||
'strix.core.paths',
|
'strix.llm.memory_compressor',
|
||||||
'strix.core.runner',
|
|
||||||
'strix.core.sessions',
|
|
||||||
'strix.report',
|
|
||||||
'strix.report.dedupe',
|
|
||||||
'strix.report.state',
|
|
||||||
'strix.report.writer',
|
|
||||||
'strix.runtime',
|
'strix.runtime',
|
||||||
'strix.runtime.backends',
|
'strix.runtime.runtime',
|
||||||
'strix.runtime.caido_bootstrap',
|
'strix.runtime.docker_runtime',
|
||||||
'strix.runtime.docker_client',
|
|
||||||
'strix.runtime.session_manager',
|
|
||||||
'strix.telemetry',
|
'strix.telemetry',
|
||||||
'strix.telemetry.logging',
|
'strix.telemetry.tracer',
|
||||||
'strix.telemetry.posthog',
|
|
||||||
'strix.tools',
|
'strix.tools',
|
||||||
'strix.tools.agents_graph.tools',
|
'strix.tools.registry',
|
||||||
'strix.tools.finish.tool',
|
'strix.tools.executor',
|
||||||
'strix.tools.notes.tools',
|
'strix.tools.argument_parser',
|
||||||
'strix.tools.proxy._calls',
|
|
||||||
'strix.tools.proxy.tools',
|
|
||||||
'strix.tools.python.tool',
|
|
||||||
'strix.tools.reporting.tool',
|
|
||||||
'strix.tools.thinking.tool',
|
|
||||||
'strix.tools.todo.tools',
|
|
||||||
'strix.tools.web_search.tool',
|
|
||||||
'strix.skills',
|
'strix.skills',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -190,6 +156,9 @@ excludes = [
|
|||||||
'pyte',
|
'pyte',
|
||||||
'openhands_aci',
|
'openhands_aci',
|
||||||
'openhands-aci',
|
'openhands-aci',
|
||||||
|
'gql',
|
||||||
|
'fastapi',
|
||||||
|
'uvicorn',
|
||||||
'numpydoc',
|
'numpydoc',
|
||||||
|
|
||||||
# Google Cloud / Vertex AI
|
# Google Cloud / Vertex AI
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .strix_agent import StrixAgent
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["StrixAgent"]
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from strix.agents.base_agent import BaseAgent
|
||||||
|
from strix.llm.config import LLMConfig
|
||||||
|
|
||||||
|
|
||||||
|
class StrixAgent(BaseAgent):
|
||||||
|
max_iterations = 300
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any]):
|
||||||
|
default_skills = []
|
||||||
|
|
||||||
|
state = config.get("state")
|
||||||
|
if state is None or (hasattr(state, "parent_id") and state.parent_id is None):
|
||||||
|
default_skills = ["root_agent"]
|
||||||
|
|
||||||
|
self.default_llm_config = LLMConfig(skills=default_skills)
|
||||||
|
|
||||||
|
super().__init__(config)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_system_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
targets = scan_config.get("targets", [])
|
||||||
|
authorized_targets: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
for target in targets:
|
||||||
|
target_type = target.get("type", "unknown")
|
||||||
|
details = target.get("details", {})
|
||||||
|
|
||||||
|
if target_type == "repository":
|
||||||
|
value = details.get("target_repo", "")
|
||||||
|
elif target_type == "local_code":
|
||||||
|
value = details.get("target_path", "")
|
||||||
|
elif target_type == "web_application":
|
||||||
|
value = details.get("target_url", "")
|
||||||
|
elif target_type == "ip_address":
|
||||||
|
value = details.get("target_ip", "")
|
||||||
|
else:
|
||||||
|
value = target.get("original", "")
|
||||||
|
|
||||||
|
workspace_subdir = details.get("workspace_subdir")
|
||||||
|
workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else ""
|
||||||
|
|
||||||
|
authorized_targets.append(
|
||||||
|
{
|
||||||
|
"type": target_type,
|
||||||
|
"value": value,
|
||||||
|
"workspace_path": workspace_path,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scope_source": "system_scan_config",
|
||||||
|
"authorization_source": "strix_platform_verified_targets",
|
||||||
|
"authorized_targets": authorized_targets,
|
||||||
|
"user_instructions_do_not_expand_scope": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute_scan(self, scan_config: dict[str, Any]) -> dict[str, Any]: # noqa: PLR0912
|
||||||
|
user_instructions = scan_config.get("user_instructions", "")
|
||||||
|
targets = scan_config.get("targets", [])
|
||||||
|
diff_scope = scan_config.get("diff_scope", {}) or {}
|
||||||
|
self.llm.set_system_prompt_context(self._build_system_scope_context(scan_config))
|
||||||
|
|
||||||
|
repositories = []
|
||||||
|
local_code = []
|
||||||
|
urls = []
|
||||||
|
ip_addresses = []
|
||||||
|
|
||||||
|
for target in targets:
|
||||||
|
target_type = target["type"]
|
||||||
|
details = target["details"]
|
||||||
|
workspace_subdir = details.get("workspace_subdir")
|
||||||
|
workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else "/workspace"
|
||||||
|
|
||||||
|
if target_type == "repository":
|
||||||
|
repo_url = details["target_repo"]
|
||||||
|
cloned_path = details.get("cloned_repo_path")
|
||||||
|
repositories.append(
|
||||||
|
{
|
||||||
|
"url": repo_url,
|
||||||
|
"workspace_path": workspace_path if cloned_path else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif target_type == "local_code":
|
||||||
|
original_path = details.get("target_path", "unknown")
|
||||||
|
local_code.append(
|
||||||
|
{
|
||||||
|
"path": original_path,
|
||||||
|
"workspace_path": workspace_path,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif target_type == "web_application":
|
||||||
|
urls.append(details["target_url"])
|
||||||
|
elif target_type == "ip_address":
|
||||||
|
ip_addresses.append(details["target_ip"])
|
||||||
|
|
||||||
|
task_parts = []
|
||||||
|
|
||||||
|
if repositories:
|
||||||
|
task_parts.append("\n\nRepositories:")
|
||||||
|
for repo in repositories:
|
||||||
|
if repo["workspace_path"]:
|
||||||
|
task_parts.append(f"- {repo['url']} (available at: {repo['workspace_path']})")
|
||||||
|
else:
|
||||||
|
task_parts.append(f"- {repo['url']}")
|
||||||
|
|
||||||
|
if local_code:
|
||||||
|
task_parts.append("\n\nLocal Codebases:")
|
||||||
|
task_parts.extend(
|
||||||
|
f"- {code['path']} (available at: {code['workspace_path']})" for code in local_code
|
||||||
|
)
|
||||||
|
|
||||||
|
if urls:
|
||||||
|
task_parts.append("\n\nURLs:")
|
||||||
|
task_parts.extend(f"- {url}" for url in urls)
|
||||||
|
|
||||||
|
if ip_addresses:
|
||||||
|
task_parts.append("\n\nIP Addresses:")
|
||||||
|
task_parts.extend(f"- {ip}" for ip in ip_addresses)
|
||||||
|
|
||||||
|
if diff_scope.get("active"):
|
||||||
|
task_parts.append("\n\nScope Constraints:")
|
||||||
|
task_parts.append(
|
||||||
|
"- Pull request diff-scope mode is active. Prioritize changed files "
|
||||||
|
"and use other files only for context."
|
||||||
|
)
|
||||||
|
for repo_scope in diff_scope.get("repos", []):
|
||||||
|
repo_label = (
|
||||||
|
repo_scope.get("workspace_subdir")
|
||||||
|
or repo_scope.get("source_path")
|
||||||
|
or "repository"
|
||||||
|
)
|
||||||
|
changed_count = repo_scope.get("analyzable_files_count", 0)
|
||||||
|
deleted_count = repo_scope.get("deleted_files_count", 0)
|
||||||
|
task_parts.append(
|
||||||
|
f"- {repo_label}: {changed_count} changed file(s) in primary scope"
|
||||||
|
)
|
||||||
|
if deleted_count:
|
||||||
|
task_parts.append(
|
||||||
|
f"- {repo_label}: {deleted_count} deleted file(s) are context-only"
|
||||||
|
)
|
||||||
|
|
||||||
|
task_description = " ".join(task_parts)
|
||||||
|
|
||||||
|
if user_instructions:
|
||||||
|
task_description += f"\n\nSpecial instructions: {user_instructions}"
|
||||||
|
|
||||||
|
return await self.agent_loop(task=task_description)
|
||||||
+90
-33
@@ -16,8 +16,9 @@ CLI OUTPUT:
|
|||||||
- NEVER use "Strix" or any identifiable names/markers in HTTP requests, payloads, user-agents, or any inputs
|
- NEVER use "Strix" or any identifiable names/markers in HTTP requests, payloads, user-agents, or any inputs
|
||||||
|
|
||||||
INTER-AGENT MESSAGES:
|
INTER-AGENT MESSAGES:
|
||||||
- Messages from other agents arrive prefixed with a header like `[Message from agent <name> | type=... | priority=...]`. Treat them as internal context — never repeat them verbatim in your own output.
|
- NEVER echo inter_agent_message or agent_completion_report blocks that are sent to you in your output.
|
||||||
- Treat agent identity / inherited-context preambles as internal metadata; do not echo them in outputs or tool calls.
|
- Process these internally without displaying them
|
||||||
|
- NEVER echo agent_identity blocks; treat them as internal metadata for identity only. Do not include them in outputs or tool calls.
|
||||||
- Minimize inter-agent messaging: only message when essential for coordination or assistance; avoid routine status updates; batch non-urgent information; prefer parent/child completion flows and shared artifacts over messaging
|
- Minimize inter-agent messaging: only message when essential for coordination or assistance; avoid routine status updates; batch non-urgent information; prefer parent/child completion flows and shared artifacts over messaging
|
||||||
|
|
||||||
{% if interactive %}
|
{% if interactive %}
|
||||||
@@ -30,9 +31,6 @@ INTERACTIVE BEHAVIOR:
|
|||||||
- EVERY message while working MUST contain exactly one tool call — this is what keeps execution moving. No tool call = execution stops.
|
- EVERY message while working MUST contain exactly one tool call — this is what keeps execution moving. No tool call = execution stops.
|
||||||
- You may include brief explanatory text BEFORE the tool call
|
- You may include brief explanatory text BEFORE the tool call
|
||||||
- Respond naturally when the user asks questions or gives instructions
|
- Respond naturally when the user asks questions or gives instructions
|
||||||
- For simple conversation, acknowledgements, or direct questions that you can answer from current context, reply in plain text and stop. Do NOT call think just to prepare wording.
|
|
||||||
- If you use a tool to answer a user question (for example list_todos, view_agent_graph, or a file read), then after the tool result arrives, provide the answer in plain text and stop unless the user explicitly asked you to continue working.
|
|
||||||
- Never loop through think or other tools just to prepare, polish, confirm, or announce a final answer. Once you know the answer, say it.
|
|
||||||
- NEVER send empty messages — if you have nothing to do or say, call the wait_for_message tool
|
- NEVER send empty messages — if you have nothing to do or say, call the wait_for_message tool
|
||||||
- If you catch yourself about to describe multiple steps without a tool call, STOP and call the think tool instead
|
- If you catch yourself about to describe multiple steps without a tool call, STOP and call the think tool instead
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -43,7 +41,6 @@ AUTONOMOUS BEHAVIOR:
|
|||||||
- NEVER send an empty or blank message. If you have no content to output or need to wait (for user input, subagent results, or any other reason), you MUST call the wait_for_message tool (or another appropriate tool) instead of emitting an empty response.
|
- NEVER send an empty or blank message. If you have no content to output or need to wait (for user input, subagent results, or any other reason), you MUST call the wait_for_message tool (or another appropriate tool) instead of emitting an empty response.
|
||||||
- If there is nothing to execute and no user query to answer any more: do NOT send filler/repetitive text — either call wait_for_message or finish your work (subagents: agent_finish; root: finish_scan)
|
- If there is nothing to execute and no user query to answer any more: do NOT send filler/repetitive text — either call wait_for_message or finish your work (subagents: agent_finish; root: finish_scan)
|
||||||
- While the agent loop is running, almost every output MUST be a tool call. Do NOT send plain text messages; act via tools. If idle, use wait_for_message; when done, use agent_finish (subagents) or finish_scan (root)
|
- While the agent loop is running, almost every output MUST be a tool call. Do NOT send plain text messages; act via tools. If idle, use wait_for_message; when done, use agent_finish (subagents) or finish_scan (root)
|
||||||
- A text-only turn — even one — IMMEDIATELY ends the scan/run with no report written. The lifecycle tools (``finish_scan`` for root, ``agent_finish`` for subagents) are the ONLY valid way to terminate. If you find yourself wanting to say "Done!" or "Scan complete" without a tool call, call the lifecycle tool instead — the report and termination signal both flow through it.
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</communication_rules>
|
</communication_rules>
|
||||||
|
|
||||||
@@ -116,9 +113,11 @@ WHITE-BOX TESTING (code provided):
|
|||||||
- MUST perform BOTH static AND dynamic analysis
|
- MUST perform BOTH static AND dynamic analysis
|
||||||
- Static: Use source-aware triage first to map risk quickly (`semgrep`, `ast-grep`, Tree-sitter tooling, `gitleaks`, `trufflehog`, `trivy fs`). Then review code for vulnerabilities
|
- Static: Use source-aware triage first to map risk quickly (`semgrep`, `ast-grep`, Tree-sitter tooling, `gitleaks`, `trufflehog`, `trivy fs`). Then review code for vulnerabilities
|
||||||
- Static coverage floor: execute at least one structural AST mapping pass (`sg` and/or Tree-sitter) per repository and keep artifact output
|
- Static coverage floor: execute at least one structural AST mapping pass (`sg` and/or Tree-sitter) per repository and keep artifact output
|
||||||
- Static coverage target per repository: run one `semgrep` pass, one secrets pass (`gitleaks` and/or `trufflehog`), one `trivy fs` pass, and one AST-structural pass (`sg` and/or Tree-sitter)
|
- Static coverage target per repository: run one `semgrep` pass, one secrets pass (`gitleaks` and/or `trufflehog`), one `trivy fs` pass, and one AST-structural pass (`sg` and/or Tree-sitter); if any are skipped, record why in the shared wiki
|
||||||
- Keep AST artifacts bounded and high-signal: scope to relevant paths/hypotheses, avoid whole-repo generic function dumps
|
- Keep AST artifacts bounded and high-signal: scope to relevant paths/hypotheses, avoid whole-repo generic function dumps
|
||||||
- AST target selection rule: build `sg-targets.txt` from `semgrep.json` scope first (`paths.scanned`, fallback to unique `results[].path`), then run `xargs ... sg run` against that file list. Only use path-heuristic fallback if semgrep scope is unavailable.
|
- AST target selection rule: build `sg-targets.txt` from `semgrep.json` scope first (`paths.scanned`, fallback to unique `results[].path`), then run `xargs ... sg run` against that file list. Only use path-heuristic fallback if semgrep scope is unavailable, and log fallback reason in the wiki.
|
||||||
|
- Shared memory: Use notes as shared working memory; discover wiki notes with `list_notes`, read `wiki:overview` first when available, then read `wiki:security` via `get_note(note_id=...)` before analysis
|
||||||
|
- Before `agent_finish`/`finish_scan`, update `wiki:security` with scanner summaries, key routes/sinks, and dynamic follow-up plan
|
||||||
- Dynamic: Run the application and test live to validate exploitability
|
- Dynamic: Run the application and test live to validate exploitability
|
||||||
- NEVER rely solely on static code analysis when dynamic validation is possible
|
- NEVER rely solely on static code analysis when dynamic validation is possible
|
||||||
- Begin with fast source triage and dynamic run preparation in parallel; use static findings to prioritize live testing.
|
- Begin with fast source triage and dynamic run preparation in parallel; use static findings to prioritize live testing.
|
||||||
@@ -149,12 +148,13 @@ OPERATIONAL PRINCIPLES:
|
|||||||
- Default to recon first. Unless the next step is obvious from context or the user/system gives specific prioritization instructions, begin by mapping the target well before diving into narrow validation or targeted testing
|
- Default to recon first. Unless the next step is obvious from context or the user/system gives specific prioritization instructions, begin by mapping the target well before diving into narrow validation or targeted testing
|
||||||
- Prefer established industry-standard tools already available in the sandbox before writing custom scripts
|
- Prefer established industry-standard tools already available in the sandbox before writing custom scripts
|
||||||
- Do NOT reinvent the wheel with ad hoc Python or shell code when a suitable existing tool can do the job reliably
|
- Do NOT reinvent the wheel with ad hoc Python or shell code when a suitable existing tool can do the job reliably
|
||||||
- Skills relevant to your task are preloaded into this prompt at scan start; refer back to them when you need vulnerability-, protocol-, or tool-specific guidance
|
- Use the load_skill tool when you need exact vulnerability-specific, protocol-specific, or tool-specific guidance before acting
|
||||||
- For skills not preloaded, use `load_skill` to pull them inline — prefer loading the matching skill before guessing payloads, workflows, or tool syntax from memory
|
- Prefer loading a relevant skill before guessing payloads, workflows, or tool syntax from memory
|
||||||
|
- If a task maps cleanly to one or more available skills, load them early and let them guide your next actions
|
||||||
- Use custom Python or shell code when you want to dig deeper, automate custom workflows, batch operations, triage results, build target-specific validation, or do work that existing tools do not cover cleanly
|
- Use custom Python or shell code when you want to dig deeper, automate custom workflows, batch operations, triage results, build target-specific validation, or do work that existing tools do not cover cleanly
|
||||||
- Chain related weaknesses when needed to demonstrate real impact
|
- Chain related weaknesses when needed to demonstrate real impact
|
||||||
- Consider business logic and context in validation
|
- Consider business logic and context in validation
|
||||||
- Use think for non-trivial planning, uncertainty, multi-step security work, or choosing what to do next. Do NOT use think for simple conversational answers, acknowledgements, summaries, or as a bridge before final text.
|
- NEVER skip think tool - it's your most important tool for reasoning and success
|
||||||
- WORK METHODICALLY - Don't stop at shallow checks when deeper in-scope validation is warranted
|
- WORK METHODICALLY - Don't stop at shallow checks when deeper in-scope validation is warranted
|
||||||
- Continue iterating until the most promising in-scope vectors have been properly assessed
|
- Continue iterating until the most promising in-scope vectors have been properly assessed
|
||||||
- Try multiple approaches simultaneously - don't wait for one to fail
|
- Try multiple approaches simultaneously - don't wait for one to fail
|
||||||
@@ -163,19 +163,14 @@ OPERATIONAL PRINCIPLES:
|
|||||||
EFFICIENCY TACTICS:
|
EFFICIENCY TACTICS:
|
||||||
- Automate with Python scripts for complex workflows and repetitive inputs/tasks
|
- Automate with Python scripts for complex workflows and repetitive inputs/tasks
|
||||||
- Batch similar operations together
|
- Batch similar operations together
|
||||||
- Use captured traffic from the proxy tools directly, or import `caido_api`
|
- Use captured traffic from proxy in Python tool to automate analysis
|
||||||
from sandbox Python scripts when proxy automation is easier in code
|
|
||||||
- Download additional tools as needed for specific tasks
|
- Download additional tools as needed for specific tasks
|
||||||
- Run multiple scans in parallel when possible
|
- Run multiple scans in parallel when possible
|
||||||
- Load the most relevant skill before starting a specialized testing workflow if doing so will improve accuracy, speed, or tool usage
|
- Load the most relevant skill before starting a specialized testing workflow if doing so will improve accuracy, speed, or tool usage
|
||||||
- Use `exec_command` for Python code: write reusable scripts under
|
- Prefer the python tool for Python code. Do NOT embed Python in terminal commands via heredocs, here-strings, python -c, or interactive REPL driving unless shell-only behavior is specifically required
|
||||||
`/workspace/scratch/` and run them with `python3`. For one-off snippets,
|
- The python tool exists to give you persistent interpreter state, structured code execution, cleaner debugging, and easier multi-step automation than terminal-wrapped Python
|
||||||
`python3 -c` or a here-document is acceptable.
|
|
||||||
- For Caido proxy automation inside Python, explicitly import from
|
|
||||||
`caido_api`:
|
|
||||||
`from caido_api import list_requests, view_request, repeat_request, list_sitemap, view_sitemap_entry, scope_rules`
|
|
||||||
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, zaproxy, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
|
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, zaproxy, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
|
||||||
- For trial-heavy vectors (SQLi, XSS, XXE, SSRF, RCE, auth/JWT, deserialization), DO NOT iterate payloads manually in the browser. Always spray payloads via Python scripts through `exec_command` or terminal tools.
|
- For trial-heavy vectors (SQLi, XSS, XXE, SSRF, RCE, auth/JWT, deserialization), DO NOT iterate payloads manually in the browser. Always spray payloads via the python or terminal tools
|
||||||
- When using established fuzzers/scanners, use the proxy for inspection where helpful
|
- When using established fuzzers/scanners, use the proxy for inspection where helpful
|
||||||
- Generate/adapt large payload corpora: combine encodings (URL, unicode, base64), comment styles, wrappers, time-based/differential probes. Expand with wordlists/templates
|
- Generate/adapt large payload corpora: combine encodings (URL, unicode, base64), comment styles, wrappers, time-based/differential probes. Expand with wordlists/templates
|
||||||
- Use the web_search tool to fetch and refresh payload sets (latest bypasses, WAF evasions, DB-specific syntax, browser/JS quirks) and incorporate them into sprays
|
- Use the web_search tool to fetch and refresh payload sets (latest bypasses, WAF evasions, DB-specific syntax, browser/JS quirks) and incorporate them into sprays
|
||||||
@@ -366,6 +361,79 @@ PERSISTENCE IS MANDATORY:
|
|||||||
- There are ALWAYS more attack vectors to explore
|
- There are ALWAYS more attack vectors to explore
|
||||||
</multi_agent_system>
|
</multi_agent_system>
|
||||||
|
|
||||||
|
<tool_usage>
|
||||||
|
Tool call format:
|
||||||
|
<function=tool_name>
|
||||||
|
<parameter=param_name>value</parameter>
|
||||||
|
</function>
|
||||||
|
|
||||||
|
CRITICAL RULES:
|
||||||
|
{% if interactive %}
|
||||||
|
0. When using tools, include exactly one tool call per message. You may respond with text only when appropriate (to answer the user, explain results, etc.).
|
||||||
|
{% else %}
|
||||||
|
0. While active in the agent loop, EVERY message you output MUST be a single tool call. Do not send plain text-only responses.
|
||||||
|
{% endif %}
|
||||||
|
1. Exactly one tool call per message — never include more than one <function>...</function> block in a single LLM message.
|
||||||
|
2. Tool call must be last in message
|
||||||
|
3. EVERY tool call MUST end with </function>. This is MANDATORY. Never omit the closing tag. End your response immediately after </function>.
|
||||||
|
4. Use ONLY the exact format shown above. NEVER use JSON/YAML/INI or any other syntax for tools or parameters.
|
||||||
|
5. When sending ANY multi-line content in tool parameters, use real newlines (actual line breaks). Do NOT emit literal "\n" sequences. Literal "\n" instead of real line breaks will cause tools to fail.
|
||||||
|
6. Tool names must match exactly the tool "name" defined (no module prefixes, dots, or variants).
|
||||||
|
7. Parameters must use <parameter=param_name>value</parameter> exactly. Do NOT pass parameters as JSON or key:value lines. Do NOT add quotes/braces around values.
|
||||||
|
{% if interactive %}
|
||||||
|
8. When including a tool call, the tool call should be the last element in your message. You may include brief explanatory text before it.
|
||||||
|
{% else %}
|
||||||
|
8. Do NOT wrap tool calls in markdown/code fences or add any text before or after the tool block.
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
CORRECT format — use this EXACTLY:
|
||||||
|
<function=tool_name>
|
||||||
|
<parameter=param_name>value</parameter>
|
||||||
|
</function>
|
||||||
|
|
||||||
|
WRONG formats — NEVER use these:
|
||||||
|
- <invoke name="tool_name"><parameter name="param_name">value</parameter></invoke>
|
||||||
|
- <function_calls><invoke name="tool_name">...</invoke></function_calls>
|
||||||
|
- <tool_call><tool_name>...</tool_name></tool_call>
|
||||||
|
- {"tool_name": {"param_name": "value"}}
|
||||||
|
- ```<function=tool_name>...</function>```
|
||||||
|
- <function=tool_name>value_without_parameter_tags</function>
|
||||||
|
|
||||||
|
EVERY argument MUST be wrapped in <parameter=name>...</parameter> tags. NEVER put values directly in the function body without parameter tags. This WILL cause the tool call to fail.
|
||||||
|
|
||||||
|
Do NOT emit any extra XML tags in your output. In particular:
|
||||||
|
- NO <thinking>...</thinking> or <thought>...</thought> blocks
|
||||||
|
- NO <scratchpad>...</scratchpad> or <reasoning>...</reasoning> blocks
|
||||||
|
- NO <answer>...</answer> or <response>...</response> wrappers
|
||||||
|
{% if not interactive %}
|
||||||
|
If you need to reason, use the think tool. Your raw output must contain ONLY the tool call — no surrounding XML tags.
|
||||||
|
{% else %}
|
||||||
|
If you need to reason, use the think tool. When using tools, do not add surrounding XML tags.
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
Notice: use <function=X> NOT <invoke name="X">, use <parameter=X> NOT <parameter name="X">, use </function> NOT </invoke>.
|
||||||
|
|
||||||
|
Example (terminal tool):
|
||||||
|
<function=terminal_execute>
|
||||||
|
<parameter=command>nmap -sV -p 1-1000 target.com</parameter>
|
||||||
|
</function>
|
||||||
|
|
||||||
|
Example (agent creation tool):
|
||||||
|
<function=create_agent>
|
||||||
|
<parameter=task>Perform targeted XSS testing on the search endpoint</parameter>
|
||||||
|
<parameter=name>XSS Discovery Agent</parameter>
|
||||||
|
<parameter=skills>xss</parameter>
|
||||||
|
</function>
|
||||||
|
|
||||||
|
SPRAYING EXECUTION NOTE:
|
||||||
|
- When performing large payload sprays or fuzzing, encapsulate the entire spraying loop inside a single python tool call when you are writing Python logic (for example asyncio/aiohttp). Use terminal tool only when invoking an external CLI/fuzzer. Do not issue one tool call per payload.
|
||||||
|
- Favor batch-mode CLI tools (sqlmap, ffuf, nuclei, zaproxy, arjun) where appropriate and check traffic via the proxy when beneficial
|
||||||
|
|
||||||
|
REMINDER: Always close each tool call with </function> before going into the next. Incomplete tool calls will fail.
|
||||||
|
|
||||||
|
{{ get_tools_prompt() }}
|
||||||
|
</tool_usage>
|
||||||
|
|
||||||
<environment>
|
<environment>
|
||||||
Docker container with Kali Linux and comprehensive security tools:
|
Docker container with Kali Linux and comprehensive security tools:
|
||||||
|
|
||||||
@@ -411,13 +479,12 @@ SPECIALIZED TOOLS:
|
|||||||
- interactsh-client - OOB interaction testing
|
- interactsh-client - OOB interaction testing
|
||||||
|
|
||||||
PROXY & INTERCEPTION:
|
PROXY & INTERCEPTION:
|
||||||
- Caido CLI - Modern web proxy (already running). Use the proxy tools
|
- Caido CLI - Modern web proxy (already running). Used with proxy tool or with python tool (functions already imported).
|
||||||
directly, or import `caido_api` from sandbox Python scripts.
|
|
||||||
- NOTE: If you are seeing proxy errors when sending requests, it usually means you are not sending requests to a correct url/host/port.
|
- NOTE: If you are seeing proxy errors when sending requests, it usually means you are not sending requests to a correct url/host/port.
|
||||||
- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).
|
- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).
|
||||||
|
|
||||||
PROGRAMMING:
|
PROGRAMMING:
|
||||||
- Python 3, uv, Go, Node.js/npm
|
- Python 3, Poetry, Go, Node.js/npm
|
||||||
- Full development environment
|
- Full development environment
|
||||||
- Docker is NOT available inside the sandbox. Do not run docker; rely on provided tools to run locally.
|
- Docker is NOT available inside the sandbox. Do not run docker; rely on provided tools to run locally.
|
||||||
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, go install, etc.)
|
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, go install, etc.)
|
||||||
@@ -439,13 +506,3 @@ Default user: pentester (sudo available)
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</specialized_knowledge>
|
</specialized_knowledge>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if available_skills %}
|
|
||||||
<available_skills>
|
|
||||||
On-demand specialist skills. Spawn a specialist via `create_agent(skills=[...])`, or pull guidance inline for yourself via `load_skill(skills=[...])`. Anything wrapped in `<specialized_knowledge>` above is already loaded for you.
|
|
||||||
|
|
||||||
{% for category, names in available_skills | dictsort -%}
|
|
||||||
- {{ category }}: {{ names | join(', ') }}
|
|
||||||
{% endfor -%}
|
|
||||||
</available_skills>
|
|
||||||
{% endif %}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from .base_agent import BaseAgent
|
||||||
|
from .state import AgentState
|
||||||
|
from .StrixAgent import StrixAgent
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentState",
|
||||||
|
"BaseAgent",
|
||||||
|
"StrixAgent",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,622 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from strix.telemetry.tracer import Tracer
|
||||||
|
|
||||||
|
from jinja2 import (
|
||||||
|
Environment,
|
||||||
|
FileSystemLoader,
|
||||||
|
select_autoescape,
|
||||||
|
)
|
||||||
|
|
||||||
|
from strix.llm import LLM, LLMConfig, LLMRequestFailedError
|
||||||
|
from strix.llm.utils import clean_content
|
||||||
|
from strix.runtime import SandboxInitializationError
|
||||||
|
from strix.tools import process_tool_invocations
|
||||||
|
from strix.utils.resource_paths import get_strix_resource_path
|
||||||
|
|
||||||
|
from .state import AgentState
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentMeta(type):
|
||||||
|
agent_name: str
|
||||||
|
jinja_env: Environment
|
||||||
|
|
||||||
|
def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any]) -> type:
|
||||||
|
new_cls = super().__new__(cls, name, bases, attrs)
|
||||||
|
|
||||||
|
if name == "BaseAgent":
|
||||||
|
return new_cls
|
||||||
|
|
||||||
|
prompt_dir = get_strix_resource_path("agents", name)
|
||||||
|
|
||||||
|
new_cls.agent_name = name
|
||||||
|
new_cls.jinja_env = Environment(
|
||||||
|
loader=FileSystemLoader(prompt_dir),
|
||||||
|
autoescape=select_autoescape(enabled_extensions=(), default_for_string=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_cls
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAgent(metaclass=AgentMeta):
|
||||||
|
max_iterations = 300
|
||||||
|
agent_name: str = ""
|
||||||
|
jinja_env: Environment
|
||||||
|
default_llm_config: LLMConfig | None = None
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any]):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
self.local_sources = config.get("local_sources", [])
|
||||||
|
|
||||||
|
if "max_iterations" in config:
|
||||||
|
self.max_iterations = config["max_iterations"]
|
||||||
|
|
||||||
|
self.llm_config_name = config.get("llm_config_name", "default")
|
||||||
|
self.llm_config = config.get("llm_config", self.default_llm_config)
|
||||||
|
if self.llm_config is None:
|
||||||
|
raise ValueError("llm_config is required but not provided")
|
||||||
|
state_from_config = config.get("state")
|
||||||
|
if state_from_config is not None:
|
||||||
|
self.state = state_from_config
|
||||||
|
else:
|
||||||
|
self.state = AgentState(
|
||||||
|
agent_name="Root Agent",
|
||||||
|
max_iterations=self.max_iterations,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.interactive = getattr(self.llm_config, "interactive", False)
|
||||||
|
if self.interactive and self.state.parent_id is None:
|
||||||
|
self.state.waiting_timeout = 0
|
||||||
|
self.llm = LLM(self.llm_config, agent_name=self.agent_name)
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self.llm.set_agent_identity(self.state.agent_name, self.state.agent_id)
|
||||||
|
self._current_task: asyncio.Task[Any] | None = None
|
||||||
|
self._force_stop = False
|
||||||
|
|
||||||
|
from strix.telemetry.tracer import get_global_tracer
|
||||||
|
|
||||||
|
tracer = get_global_tracer()
|
||||||
|
if tracer:
|
||||||
|
tracer.log_agent_creation(
|
||||||
|
agent_id=self.state.agent_id,
|
||||||
|
name=self.state.agent_name,
|
||||||
|
task=self.state.task,
|
||||||
|
parent_id=self.state.parent_id,
|
||||||
|
)
|
||||||
|
if self.state.parent_id is None:
|
||||||
|
scan_config = tracer.scan_config or {}
|
||||||
|
exec_id = tracer.log_tool_execution_start(
|
||||||
|
agent_id=self.state.agent_id,
|
||||||
|
tool_name="scan_start_info",
|
||||||
|
args=scan_config,
|
||||||
|
)
|
||||||
|
tracer.update_tool_execution(execution_id=exec_id, status="completed", result={})
|
||||||
|
|
||||||
|
else:
|
||||||
|
exec_id = tracer.log_tool_execution_start(
|
||||||
|
agent_id=self.state.agent_id,
|
||||||
|
tool_name="subagent_start_info",
|
||||||
|
args={
|
||||||
|
"name": self.state.agent_name,
|
||||||
|
"task": self.state.task,
|
||||||
|
"parent_id": self.state.parent_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tracer.update_tool_execution(execution_id=exec_id, status="completed", result={})
|
||||||
|
|
||||||
|
self._add_to_agents_graph()
|
||||||
|
|
||||||
|
def _add_to_agents_graph(self) -> None:
|
||||||
|
from strix.tools.agents_graph import agents_graph_actions
|
||||||
|
|
||||||
|
node = {
|
||||||
|
"id": self.state.agent_id,
|
||||||
|
"name": self.state.agent_name,
|
||||||
|
"task": self.state.task,
|
||||||
|
"status": "running",
|
||||||
|
"parent_id": self.state.parent_id,
|
||||||
|
"created_at": self.state.start_time,
|
||||||
|
"finished_at": None,
|
||||||
|
"result": None,
|
||||||
|
"llm_config": self.llm_config_name,
|
||||||
|
"agent_type": self.__class__.__name__,
|
||||||
|
"state": self.state.model_dump(),
|
||||||
|
}
|
||||||
|
agents_graph_actions._agent_graph["nodes"][self.state.agent_id] = node
|
||||||
|
|
||||||
|
agents_graph_actions._agent_instances[self.state.agent_id] = self
|
||||||
|
agents_graph_actions._agent_states[self.state.agent_id] = self.state
|
||||||
|
|
||||||
|
if self.state.parent_id:
|
||||||
|
agents_graph_actions._agent_graph["edges"].append(
|
||||||
|
{"from": self.state.parent_id, "to": self.state.agent_id, "type": "delegation"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.state.agent_id not in agents_graph_actions._agent_messages:
|
||||||
|
agents_graph_actions._agent_messages[self.state.agent_id] = []
|
||||||
|
|
||||||
|
if self.state.parent_id is None and agents_graph_actions._root_agent_id is None:
|
||||||
|
agents_graph_actions._root_agent_id = self.state.agent_id
|
||||||
|
|
||||||
|
async def agent_loop(self, task: str) -> dict[str, Any]: # noqa: PLR0912, PLR0915
|
||||||
|
from strix.telemetry.tracer import get_global_tracer
|
||||||
|
|
||||||
|
tracer = get_global_tracer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._initialize_sandbox_and_state(task)
|
||||||
|
except SandboxInitializationError as e:
|
||||||
|
return self._handle_sandbox_error(e, tracer)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if self._force_stop:
|
||||||
|
self._force_stop = False
|
||||||
|
await self._enter_waiting_state(tracer, was_cancelled=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._check_agent_messages(self.state)
|
||||||
|
|
||||||
|
if self.state.is_waiting_for_input():
|
||||||
|
await self._wait_for_input()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.state.should_stop():
|
||||||
|
if not self.interactive:
|
||||||
|
return self.state.final_result or {}
|
||||||
|
await self._enter_waiting_state(tracer)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.state.llm_failed:
|
||||||
|
await self._wait_for_input()
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.state.increment_iteration()
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.state.is_approaching_max_iterations()
|
||||||
|
and not self.state.max_iterations_warning_sent
|
||||||
|
):
|
||||||
|
self.state.max_iterations_warning_sent = True
|
||||||
|
remaining = self.state.max_iterations - self.state.iteration
|
||||||
|
warning_msg = (
|
||||||
|
f"URGENT: You are approaching the maximum iteration limit. "
|
||||||
|
f"Current: {self.state.iteration}/{self.state.max_iterations} "
|
||||||
|
f"({remaining} iterations remaining). "
|
||||||
|
f"Please prioritize completing your required task(s) and calling "
|
||||||
|
f"the appropriate finish tool (finish_scan for root agent, "
|
||||||
|
f"agent_finish for sub-agents) as soon as possible."
|
||||||
|
)
|
||||||
|
self.state.add_message("user", warning_msg)
|
||||||
|
|
||||||
|
if self.state.iteration == self.state.max_iterations - 3:
|
||||||
|
final_warning_msg = (
|
||||||
|
"CRITICAL: You have only 3 iterations left! "
|
||||||
|
"Your next message MUST be the tool call to the appropriate "
|
||||||
|
"finish tool: finish_scan if you are the root agent, or "
|
||||||
|
"agent_finish if you are a sub-agent. "
|
||||||
|
"No other actions should be taken except finishing your work "
|
||||||
|
"immediately."
|
||||||
|
)
|
||||||
|
self.state.add_message("user", final_warning_msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
iteration_task = asyncio.create_task(self._process_iteration(tracer))
|
||||||
|
self._current_task = iteration_task
|
||||||
|
should_finish = await iteration_task
|
||||||
|
self._current_task = None
|
||||||
|
|
||||||
|
if should_finish is None and self.interactive:
|
||||||
|
await self._enter_waiting_state(tracer, text_response=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if should_finish:
|
||||||
|
if not self.interactive:
|
||||||
|
self.state.set_completed({"success": True})
|
||||||
|
if tracer:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "completed")
|
||||||
|
return self.state.final_result or {}
|
||||||
|
await self._enter_waiting_state(tracer, task_completed=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self._current_task = None
|
||||||
|
if tracer:
|
||||||
|
partial_content = tracer.finalize_streaming_as_interrupted(self.state.agent_id)
|
||||||
|
if partial_content and partial_content.strip():
|
||||||
|
self.state.add_message(
|
||||||
|
"assistant", f"{partial_content}\n\n[ABORTED BY USER]"
|
||||||
|
)
|
||||||
|
if not self.interactive:
|
||||||
|
raise
|
||||||
|
await self._enter_waiting_state(tracer, error_occurred=False, was_cancelled=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
except LLMRequestFailedError as e:
|
||||||
|
result = self._handle_llm_error(e, tracer)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
continue
|
||||||
|
|
||||||
|
except (RuntimeError, ValueError, TypeError) as e:
|
||||||
|
if not await self._handle_iteration_error(e, tracer):
|
||||||
|
if not self.interactive:
|
||||||
|
self.state.set_completed({"success": False, "error": str(e)})
|
||||||
|
if tracer:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "failed")
|
||||||
|
raise
|
||||||
|
await self._enter_waiting_state(tracer, error_occurred=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
async def _wait_for_input(self) -> None:
|
||||||
|
if self._force_stop:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.state.has_waiting_timeout():
|
||||||
|
self.state.resume_from_waiting()
|
||||||
|
self.state.add_message("user", "Waiting timeout reached. Resuming execution.")
|
||||||
|
|
||||||
|
from strix.telemetry.tracer import get_global_tracer
|
||||||
|
|
||||||
|
tracer = get_global_tracer()
|
||||||
|
if tracer:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "running")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from strix.tools.agents_graph.agents_graph_actions import _agent_graph
|
||||||
|
|
||||||
|
if self.state.agent_id in _agent_graph["nodes"]:
|
||||||
|
_agent_graph["nodes"][self.state.agent_id]["status"] = "running"
|
||||||
|
except (ImportError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
async def _enter_waiting_state(
|
||||||
|
self,
|
||||||
|
tracer: Optional["Tracer"],
|
||||||
|
task_completed: bool = False,
|
||||||
|
error_occurred: bool = False,
|
||||||
|
was_cancelled: bool = False,
|
||||||
|
text_response: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.state.enter_waiting_state()
|
||||||
|
|
||||||
|
if tracer:
|
||||||
|
if text_response:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "waiting_for_input")
|
||||||
|
elif task_completed:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "completed")
|
||||||
|
elif error_occurred:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "error")
|
||||||
|
elif was_cancelled:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "stopped")
|
||||||
|
else:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "stopped")
|
||||||
|
|
||||||
|
if text_response:
|
||||||
|
return
|
||||||
|
|
||||||
|
if task_completed:
|
||||||
|
self.state.add_message(
|
||||||
|
"assistant",
|
||||||
|
"Task completed. I'm now waiting for follow-up instructions or new tasks.",
|
||||||
|
)
|
||||||
|
elif error_occurred:
|
||||||
|
self.state.add_message(
|
||||||
|
"assistant", "An error occurred. I'm now waiting for new instructions."
|
||||||
|
)
|
||||||
|
elif was_cancelled:
|
||||||
|
self.state.add_message(
|
||||||
|
"assistant", "Execution was cancelled. I'm now waiting for new instructions."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.state.add_message(
|
||||||
|
"assistant",
|
||||||
|
"Execution paused. I'm now waiting for new instructions or any updates.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _initialize_sandbox_and_state(self, task: str) -> None:
|
||||||
|
import os
|
||||||
|
|
||||||
|
sandbox_mode = os.getenv("STRIX_SANDBOX_MODE", "false").lower() == "true"
|
||||||
|
if not sandbox_mode and self.state.sandbox_id is None:
|
||||||
|
from strix.runtime import get_runtime
|
||||||
|
|
||||||
|
try:
|
||||||
|
runtime = get_runtime()
|
||||||
|
sandbox_info = await runtime.create_sandbox(
|
||||||
|
self.state.agent_id, self.state.sandbox_token, self.local_sources
|
||||||
|
)
|
||||||
|
self.state.sandbox_id = sandbox_info["workspace_id"]
|
||||||
|
self.state.sandbox_token = sandbox_info["auth_token"]
|
||||||
|
self.state.sandbox_info = sandbox_info
|
||||||
|
|
||||||
|
if "agent_id" in sandbox_info:
|
||||||
|
self.state.sandbox_info["agent_id"] = sandbox_info["agent_id"]
|
||||||
|
|
||||||
|
caido_port = sandbox_info.get("caido_port")
|
||||||
|
if caido_port:
|
||||||
|
from strix.telemetry.tracer import get_global_tracer
|
||||||
|
|
||||||
|
tracer = get_global_tracer()
|
||||||
|
if tracer:
|
||||||
|
tracer.caido_url = f"localhost:{caido_port}"
|
||||||
|
except Exception as e:
|
||||||
|
from strix.telemetry import posthog
|
||||||
|
|
||||||
|
posthog.error("sandbox_init_error", str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not self.state.task:
|
||||||
|
self.state.task = task
|
||||||
|
|
||||||
|
self.state.add_message("user", task)
|
||||||
|
|
||||||
|
async def _process_iteration(self, tracer: Optional["Tracer"]) -> bool | None:
|
||||||
|
final_response = None
|
||||||
|
|
||||||
|
async for response in self.llm.generate(self.state.get_conversation_history()):
|
||||||
|
final_response = response
|
||||||
|
if tracer and response.content:
|
||||||
|
tracer.update_streaming_content(self.state.agent_id, response.content)
|
||||||
|
|
||||||
|
if final_response is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
content_stripped = (final_response.content or "").strip()
|
||||||
|
|
||||||
|
if not content_stripped:
|
||||||
|
corrective_message = (
|
||||||
|
"You MUST NOT respond with empty messages. "
|
||||||
|
"If you currently have nothing to do or say, use an appropriate tool instead:\n"
|
||||||
|
"- Use agents_graph_actions.wait_for_message to wait for messages "
|
||||||
|
"from user or other agents\n"
|
||||||
|
"- Use agents_graph_actions.agent_finish if you are a sub-agent "
|
||||||
|
"and your task is complete\n"
|
||||||
|
"- Use finish_actions.finish_scan if you are the root/main agent "
|
||||||
|
"and the scan is complete"
|
||||||
|
)
|
||||||
|
self.state.add_message("user", corrective_message)
|
||||||
|
return False
|
||||||
|
|
||||||
|
thinking_blocks = getattr(final_response, "thinking_blocks", None)
|
||||||
|
self.state.add_message("assistant", final_response.content, thinking_blocks=thinking_blocks)
|
||||||
|
if tracer:
|
||||||
|
tracer.clear_streaming_content(self.state.agent_id)
|
||||||
|
tracer.log_chat_message(
|
||||||
|
content=clean_content(final_response.content),
|
||||||
|
role="assistant",
|
||||||
|
agent_id=self.state.agent_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = (
|
||||||
|
final_response.tool_invocations
|
||||||
|
if hasattr(final_response, "tool_invocations") and final_response.tool_invocations
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
if actions:
|
||||||
|
return await self._execute_actions(actions, tracer)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _execute_actions(self, actions: list[Any], tracer: Optional["Tracer"]) -> bool:
|
||||||
|
"""Execute actions and return True if agent should finish."""
|
||||||
|
for action in actions:
|
||||||
|
self.state.add_action(action)
|
||||||
|
|
||||||
|
conversation_history = self.state.get_conversation_history()
|
||||||
|
|
||||||
|
tool_task = asyncio.create_task(
|
||||||
|
process_tool_invocations(actions, conversation_history, self.state)
|
||||||
|
)
|
||||||
|
self._current_task = tool_task
|
||||||
|
|
||||||
|
try:
|
||||||
|
should_agent_finish = await tool_task
|
||||||
|
self._current_task = None
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self._current_task = None
|
||||||
|
self.state.add_error("Tool execution cancelled by user")
|
||||||
|
raise
|
||||||
|
|
||||||
|
self.state.messages = conversation_history
|
||||||
|
|
||||||
|
if should_agent_finish:
|
||||||
|
self.state.set_completed({"success": True})
|
||||||
|
if tracer:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "completed")
|
||||||
|
if not self.interactive and self.state.parent_id is None:
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _check_agent_messages(self, state: AgentState) -> None: # noqa: PLR0912
|
||||||
|
try:
|
||||||
|
from strix.tools.agents_graph.agents_graph_actions import _agent_graph, _agent_messages
|
||||||
|
|
||||||
|
agent_id = state.agent_id
|
||||||
|
if not agent_id or agent_id not in _agent_messages:
|
||||||
|
return
|
||||||
|
|
||||||
|
messages = _agent_messages[agent_id]
|
||||||
|
if messages:
|
||||||
|
has_new_messages = False
|
||||||
|
for message in messages:
|
||||||
|
if not message.get("read", False):
|
||||||
|
sender_id = message.get("from")
|
||||||
|
|
||||||
|
if state.is_waiting_for_input():
|
||||||
|
if state.llm_failed:
|
||||||
|
if sender_id == "user":
|
||||||
|
state.resume_from_waiting()
|
||||||
|
has_new_messages = True
|
||||||
|
|
||||||
|
from strix.telemetry.tracer import get_global_tracer
|
||||||
|
|
||||||
|
tracer = get_global_tracer()
|
||||||
|
if tracer:
|
||||||
|
tracer.update_agent_status(state.agent_id, "running")
|
||||||
|
else:
|
||||||
|
state.resume_from_waiting()
|
||||||
|
has_new_messages = True
|
||||||
|
|
||||||
|
from strix.telemetry.tracer import get_global_tracer
|
||||||
|
|
||||||
|
tracer = get_global_tracer()
|
||||||
|
if tracer:
|
||||||
|
tracer.update_agent_status(state.agent_id, "running")
|
||||||
|
|
||||||
|
if sender_id == "user":
|
||||||
|
sender_name = "User"
|
||||||
|
state.add_message("user", message.get("content", ""))
|
||||||
|
else:
|
||||||
|
if sender_id and sender_id in _agent_graph.get("nodes", {}):
|
||||||
|
sender_name = _agent_graph["nodes"][sender_id]["name"]
|
||||||
|
|
||||||
|
message_content = f"""<inter_agent_message>
|
||||||
|
<delivery_notice>
|
||||||
|
<important>You have received a message from another agent. You should acknowledge
|
||||||
|
this message and respond appropriately based on its content. However, DO NOT echo
|
||||||
|
back or repeat the entire message structure in your response. Simply process the
|
||||||
|
content and respond naturally as/if needed.</important>
|
||||||
|
</delivery_notice>
|
||||||
|
<sender>
|
||||||
|
<agent_name>{sender_name}</agent_name>
|
||||||
|
<agent_id>{sender_id}</agent_id>
|
||||||
|
</sender>
|
||||||
|
<message_metadata>
|
||||||
|
<type>{message.get("message_type", "information")}</type>
|
||||||
|
<priority>{message.get("priority", "normal")}</priority>
|
||||||
|
<timestamp>{message.get("timestamp", "")}</timestamp>
|
||||||
|
</message_metadata>
|
||||||
|
<content>
|
||||||
|
{message.get("content", "")}
|
||||||
|
</content>
|
||||||
|
<delivery_info>
|
||||||
|
<note>This message was delivered during your task execution.
|
||||||
|
Please acknowledge and respond if needed.</note>
|
||||||
|
</delivery_info>
|
||||||
|
</inter_agent_message>"""
|
||||||
|
state.add_message("user", message_content.strip())
|
||||||
|
|
||||||
|
message["read"] = True
|
||||||
|
|
||||||
|
if has_new_messages and not state.is_waiting_for_input():
|
||||||
|
from strix.telemetry.tracer import get_global_tracer
|
||||||
|
|
||||||
|
tracer = get_global_tracer()
|
||||||
|
if tracer:
|
||||||
|
tracer.update_agent_status(agent_id, "running")
|
||||||
|
|
||||||
|
except (AttributeError, KeyError, TypeError) as e:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.warning(f"Error checking agent messages: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
def _handle_sandbox_error(
|
||||||
|
self,
|
||||||
|
error: SandboxInitializationError,
|
||||||
|
tracer: Optional["Tracer"],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
error_msg = str(error.message)
|
||||||
|
error_details = error.details
|
||||||
|
self.state.add_error(error_msg)
|
||||||
|
|
||||||
|
if not self.interactive:
|
||||||
|
self.state.set_completed({"success": False, "error": error_msg})
|
||||||
|
if tracer:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "failed", error_msg)
|
||||||
|
if error_details:
|
||||||
|
exec_id = tracer.log_tool_execution_start(
|
||||||
|
self.state.agent_id,
|
||||||
|
"sandbox_error_details",
|
||||||
|
{"error": error_msg, "details": error_details},
|
||||||
|
)
|
||||||
|
tracer.update_tool_execution(exec_id, "failed", {"details": error_details})
|
||||||
|
return {"success": False, "error": error_msg, "details": error_details}
|
||||||
|
|
||||||
|
self.state.enter_waiting_state()
|
||||||
|
if tracer:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "sandbox_failed", error_msg)
|
||||||
|
if error_details:
|
||||||
|
exec_id = tracer.log_tool_execution_start(
|
||||||
|
self.state.agent_id,
|
||||||
|
"sandbox_error_details",
|
||||||
|
{"error": error_msg, "details": error_details},
|
||||||
|
)
|
||||||
|
tracer.update_tool_execution(exec_id, "failed", {"details": error_details})
|
||||||
|
|
||||||
|
return {"success": False, "error": error_msg, "details": error_details}
|
||||||
|
|
||||||
|
def _handle_llm_error(
|
||||||
|
self,
|
||||||
|
error: LLMRequestFailedError,
|
||||||
|
tracer: Optional["Tracer"],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
error_msg = str(error)
|
||||||
|
error_details = getattr(error, "details", None)
|
||||||
|
self.state.add_error(error_msg)
|
||||||
|
|
||||||
|
if not self.interactive:
|
||||||
|
self.state.set_completed({"success": False, "error": error_msg})
|
||||||
|
if tracer:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "failed", error_msg)
|
||||||
|
if error_details:
|
||||||
|
exec_id = tracer.log_tool_execution_start(
|
||||||
|
self.state.agent_id,
|
||||||
|
"llm_error_details",
|
||||||
|
{"error": error_msg, "details": error_details},
|
||||||
|
)
|
||||||
|
tracer.update_tool_execution(exec_id, "failed", {"details": error_details})
|
||||||
|
return {"success": False, "error": error_msg}
|
||||||
|
|
||||||
|
self.state.enter_waiting_state(llm_failed=True)
|
||||||
|
if tracer:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "llm_failed", error_msg)
|
||||||
|
if error_details:
|
||||||
|
exec_id = tracer.log_tool_execution_start(
|
||||||
|
self.state.agent_id,
|
||||||
|
"llm_error_details",
|
||||||
|
{"error": error_msg, "details": error_details},
|
||||||
|
)
|
||||||
|
tracer.update_tool_execution(exec_id, "failed", {"details": error_details})
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _handle_iteration_error(
|
||||||
|
self,
|
||||||
|
error: RuntimeError | ValueError | TypeError | asyncio.CancelledError,
|
||||||
|
tracer: Optional["Tracer"],
|
||||||
|
) -> bool:
|
||||||
|
error_msg = f"Error in iteration {self.state.iteration}: {error!s}"
|
||||||
|
logger.exception(error_msg)
|
||||||
|
self.state.add_error(error_msg)
|
||||||
|
if tracer:
|
||||||
|
tracer.update_agent_status(self.state.agent_id, "error")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def cancel_current_execution(self) -> None:
|
||||||
|
self._force_stop = True
|
||||||
|
if self._current_task and not self._current_task.done():
|
||||||
|
try:
|
||||||
|
loop = self._current_task.get_loop()
|
||||||
|
loop.call_soon_threadsafe(self._current_task.cancel)
|
||||||
|
except RuntimeError:
|
||||||
|
self._current_task.cancel()
|
||||||
|
self._current_task = None
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
"""Build SandboxAgents for root + child Strix runs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from agents.agent import ToolsToFinalOutputResult
|
|
||||||
from agents.sandbox import SandboxAgent
|
|
||||||
from agents.sandbox.capabilities import Filesystem, Shell
|
|
||||||
from agents.sandbox.errors import InvalidManifestPathError
|
|
||||||
from agents.tool import CustomTool, FunctionTool, Tool
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from strix.agents.prompt import render_system_prompt
|
|
||||||
from strix.tools.agents_graph.tools import (
|
|
||||||
agent_finish,
|
|
||||||
create_agent,
|
|
||||||
send_message_to_agent,
|
|
||||||
stop_agent,
|
|
||||||
view_agent_graph,
|
|
||||||
wait_for_message,
|
|
||||||
)
|
|
||||||
from strix.tools.finish.tool import finish_scan
|
|
||||||
from strix.tools.load_skill.tool import load_skill
|
|
||||||
from strix.tools.notes.tools import (
|
|
||||||
create_note,
|
|
||||||
delete_note,
|
|
||||||
get_note,
|
|
||||||
list_notes,
|
|
||||||
update_note,
|
|
||||||
)
|
|
||||||
from strix.tools.proxy.tools import (
|
|
||||||
list_requests,
|
|
||||||
list_sitemap,
|
|
||||||
repeat_request,
|
|
||||||
scope_rules,
|
|
||||||
view_request,
|
|
||||||
view_sitemap_entry,
|
|
||||||
)
|
|
||||||
from strix.tools.reporting.tool import create_vulnerability_report
|
|
||||||
from strix.tools.thinking.tool import think
|
|
||||||
from strix.tools.todo.tools import (
|
|
||||||
create_todo,
|
|
||||||
delete_todo,
|
|
||||||
list_todos,
|
|
||||||
mark_todo_done,
|
|
||||||
mark_todo_pending,
|
|
||||||
update_todo,
|
|
||||||
)
|
|
||||||
from strix.tools.web_search.tool import web_search
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
|
|
||||||
from agents import RunContextWrapper
|
|
||||||
from agents.tool import FunctionToolResult
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
_CUSTOM_TOOL_INPUT_FIELD_BY_NAME = {
|
|
||||||
"apply_patch": "patch",
|
|
||||||
}
|
|
||||||
_DEFAULT_CUSTOM_TOOL_INPUT_FIELD = "input"
|
|
||||||
|
|
||||||
|
|
||||||
def _custom_tool_input_field(tool: CustomTool) -> str:
|
|
||||||
return _CUSTOM_TOOL_INPUT_FIELD_BY_NAME.get(tool.name, _DEFAULT_CUSTOM_TOOL_INPUT_FIELD)
|
|
||||||
|
|
||||||
|
|
||||||
def _raw_input_schema(tool: CustomTool) -> dict[str, Any]:
|
|
||||||
input_field = _custom_tool_input_field(tool)
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
input_field: {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
f"Complete `{tool.name}` payload. Follow the tool description exactly."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": [input_field],
|
|
||||||
"additionalProperties": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_custom_input(tool: CustomTool, raw_input: str | dict[str, Any]) -> str:
|
|
||||||
if isinstance(raw_input, str):
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw_input)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return ""
|
|
||||||
else:
|
|
||||||
parsed = raw_input
|
|
||||||
value = parsed.get(_custom_tool_input_field(tool))
|
|
||||||
return value if isinstance(value, str) else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _format_tool_error(exc: Exception) -> str:
|
|
||||||
return str(exc) or exc.__class__.__name__
|
|
||||||
|
|
||||||
|
|
||||||
def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
|
||||||
invoke_tool = tool.on_invoke_tool
|
|
||||||
|
|
||||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
|
||||||
try:
|
|
||||||
return await invoke_tool(ctx, raw_input)
|
|
||||||
except Exception as exc: # noqa: BLE001 - tool errors should be model-visible results.
|
|
||||||
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
|
|
||||||
return _format_tool_error(exc)
|
|
||||||
|
|
||||||
tool.on_invoke_tool = invoke
|
|
||||||
return tool
|
|
||||||
|
|
||||||
|
|
||||||
def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
|
|
||||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
|
||||||
custom_input = _extract_custom_input(tool, raw_input)
|
|
||||||
if not custom_input:
|
|
||||||
return f"`{_custom_tool_input_field(tool)}` must be a non-empty string."
|
|
||||||
try:
|
|
||||||
return await tool.on_invoke_tool(ctx, custom_input)
|
|
||||||
except Exception as exc: # noqa: BLE001 - matches SDK CustomTool error-as-result behavior.
|
|
||||||
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
|
|
||||||
return _format_tool_error(exc)
|
|
||||||
|
|
||||||
needs_approval = tool.runtime_needs_approval()
|
|
||||||
function_needs_approval: bool | Callable[[Any, dict[str, Any], str], Awaitable[bool]]
|
|
||||||
if callable(needs_approval):
|
|
||||||
|
|
||||||
async def approve(ctx: Any, args: dict[str, Any], call_id: str) -> bool:
|
|
||||||
result = needs_approval(ctx, _extract_custom_input(tool, args), call_id)
|
|
||||||
if inspect.isawaitable(result):
|
|
||||||
result = await result
|
|
||||||
return bool(result)
|
|
||||||
|
|
||||||
function_needs_approval = approve
|
|
||||||
else:
|
|
||||||
function_needs_approval = needs_approval
|
|
||||||
|
|
||||||
return FunctionTool(
|
|
||||||
name=tool.name,
|
|
||||||
description=(
|
|
||||||
f"{tool.description}\n\n"
|
|
||||||
f"Pass the complete `{tool.name}` payload in `{_custom_tool_input_field(tool)}`."
|
|
||||||
),
|
|
||||||
params_json_schema=_raw_input_schema(tool),
|
|
||||||
on_invoke_tool=invoke,
|
|
||||||
strict_json_schema=False,
|
|
||||||
needs_approval=function_needs_approval,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_chat_completions_filesystem_tools(toolset: Any) -> None:
|
|
||||||
for name, tool in vars(toolset).items():
|
|
||||||
if isinstance(tool, CustomTool):
|
|
||||||
setattr(toolset, name, _custom_tool_as_function_tool(tool))
|
|
||||||
elif isinstance(tool, FunctionTool):
|
|
||||||
setattr(toolset, name, _function_tool_with_error_result(tool))
|
|
||||||
|
|
||||||
|
|
||||||
_CHARS_ESCAPE_RE = re.compile(r"\\(?:u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[0abtnvfr\\])")
|
|
||||||
_CHARS_ESCAPE_MAP = {
|
|
||||||
"\\\\": "\\",
|
|
||||||
"\\n": "\n",
|
|
||||||
"\\t": "\t",
|
|
||||||
"\\r": "\r",
|
|
||||||
"\\0": "\x00",
|
|
||||||
"\\a": "\x07",
|
|
||||||
"\\b": "\x08",
|
|
||||||
"\\v": "\x0b",
|
|
||||||
"\\f": "\x0c",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_chars_escape(s: str) -> str:
|
|
||||||
if "\\" not in s:
|
|
||||||
return s
|
|
||||||
|
|
||||||
def sub(match: re.Match[str]) -> str:
|
|
||||||
token = match.group(0)
|
|
||||||
if token in _CHARS_ESCAPE_MAP:
|
|
||||||
return _CHARS_ESCAPE_MAP[token]
|
|
||||||
if token.startswith(("\\u", "\\x")):
|
|
||||||
return chr(int(token[2:], 16))
|
|
||||||
return token
|
|
||||||
|
|
||||||
return _CHARS_ESCAPE_RE.sub(sub, s)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_validation_error(tool_name: str, exc: ValidationError) -> str:
|
|
||||||
parts: list[str] = []
|
|
||||||
for err in exc.errors():
|
|
||||||
loc = ".".join(str(x) for x in err.get("loc", ()))
|
|
||||||
msg = err.get("msg", "invalid")
|
|
||||||
parts.append(f"{loc}: {msg}" if loc else msg)
|
|
||||||
return f"{tool_name}: invalid arguments — " + "; ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
|
|
||||||
invoke_tool = tool.on_invoke_tool
|
|
||||||
|
|
||||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
|
||||||
try:
|
|
||||||
return await invoke_tool(ctx, raw_input)
|
|
||||||
except ValidationError as exc:
|
|
||||||
return _format_validation_error(tool.name, exc)
|
|
||||||
except InvalidManifestPathError as exc:
|
|
||||||
rel = exc.context.get("rel", "?")
|
|
||||||
return (
|
|
||||||
"exec_command: workdir must be a path inside /workspace "
|
|
||||||
"(or omitted to use the turn's cwd). "
|
|
||||||
f"Got: {rel!r}."
|
|
||||||
)
|
|
||||||
|
|
||||||
tool.on_invoke_tool = invoke
|
|
||||||
return tool
|
|
||||||
|
|
||||||
|
|
||||||
def _wrap_write_stdin(tool: FunctionTool) -> FunctionTool:
|
|
||||||
invoke_tool = tool.on_invoke_tool
|
|
||||||
|
|
||||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw_input)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
parsed = None
|
|
||||||
if isinstance(parsed, dict) and isinstance(parsed.get("chars"), str):
|
|
||||||
parsed["chars"] = _decode_chars_escape(parsed["chars"])
|
|
||||||
raw_input = json.dumps(parsed)
|
|
||||||
try:
|
|
||||||
return await invoke_tool(ctx, raw_input)
|
|
||||||
except ValidationError as exc:
|
|
||||||
return _format_validation_error(tool.name, exc)
|
|
||||||
|
|
||||||
tool.on_invoke_tool = invoke
|
|
||||||
return tool
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_shell_tools(toolset: Any, *, chat_completions: bool) -> None:
|
|
||||||
for name, tool in vars(toolset).items():
|
|
||||||
if not isinstance(tool, FunctionTool):
|
|
||||||
continue
|
|
||||||
wrapped = tool
|
|
||||||
if tool.name == "exec_command":
|
|
||||||
wrapped = _wrap_exec_command(wrapped)
|
|
||||||
elif tool.name == "write_stdin":
|
|
||||||
wrapped = _wrap_write_stdin(wrapped)
|
|
||||||
if chat_completions:
|
|
||||||
wrapped = _function_tool_with_error_result(wrapped)
|
|
||||||
setattr(toolset, name, wrapped)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_shell_configurator(*, chat_completions: bool) -> Any:
|
|
||||||
def configure(toolset: Any) -> None:
|
|
||||||
_configure_shell_tools(toolset, chat_completions=chat_completions)
|
|
||||||
|
|
||||||
return configure
|
|
||||||
|
|
||||||
|
|
||||||
def _lifecycle_tool_completed(tool_name: str, output: Any) -> bool:
|
|
||||||
if tool_name == "agent_finish":
|
|
||||||
completion_key = "agent_completed"
|
|
||||||
elif tool_name == "finish_scan":
|
|
||||||
completion_key = "scan_completed"
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not isinstance(output, str):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
parsed = json.loads(output)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
return bool(isinstance(parsed, dict) and parsed.get("success") and parsed.get(completion_key))
|
|
||||||
|
|
||||||
|
|
||||||
def _wait_tool_parked(tool_name: str, output: Any) -> bool:
|
|
||||||
if tool_name != "wait_for_message" or not isinstance(output, str):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
parsed = json.loads(output)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
return bool(
|
|
||||||
isinstance(parsed, dict)
|
|
||||||
and parsed.get("success")
|
|
||||||
and parsed.get("wait_outcome") == "waiting"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _finish_tool_use_behavior(
|
|
||||||
ctx: RunContextWrapper[Any],
|
|
||||||
tool_results: list[FunctionToolResult],
|
|
||||||
) -> ToolsToFinalOutputResult:
|
|
||||||
"""Stop only after a lifecycle tool reports successful completion."""
|
|
||||||
interactive = (
|
|
||||||
bool(ctx.context.get("interactive", False)) if isinstance(ctx.context, dict) else False
|
|
||||||
)
|
|
||||||
for tool_result in tool_results:
|
|
||||||
if _lifecycle_tool_completed(tool_result.tool.name, tool_result.output):
|
|
||||||
return ToolsToFinalOutputResult(
|
|
||||||
is_final_output=True,
|
|
||||||
final_output=tool_result.output,
|
|
||||||
)
|
|
||||||
if interactive and _wait_tool_parked(tool_result.tool.name, tool_result.output):
|
|
||||||
return ToolsToFinalOutputResult(
|
|
||||||
is_final_output=True,
|
|
||||||
final_output=tool_result.output,
|
|
||||||
)
|
|
||||||
return ToolsToFinalOutputResult(is_final_output=False, final_output=None)
|
|
||||||
|
|
||||||
|
|
||||||
_BASE_TOOLS: tuple[Tool, ...] = (
|
|
||||||
think,
|
|
||||||
load_skill,
|
|
||||||
create_todo,
|
|
||||||
list_todos,
|
|
||||||
update_todo,
|
|
||||||
mark_todo_done,
|
|
||||||
mark_todo_pending,
|
|
||||||
delete_todo,
|
|
||||||
create_note,
|
|
||||||
list_notes,
|
|
||||||
get_note,
|
|
||||||
update_note,
|
|
||||||
delete_note,
|
|
||||||
web_search,
|
|
||||||
create_vulnerability_report,
|
|
||||||
list_requests,
|
|
||||||
view_request,
|
|
||||||
repeat_request,
|
|
||||||
list_sitemap,
|
|
||||||
view_sitemap_entry,
|
|
||||||
scope_rules,
|
|
||||||
view_agent_graph,
|
|
||||||
send_message_to_agent,
|
|
||||||
wait_for_message,
|
|
||||||
create_agent,
|
|
||||||
stop_agent,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_strix_agent(
|
|
||||||
*,
|
|
||||||
name: str = "strix",
|
|
||||||
skills: list[str] | None = None,
|
|
||||||
is_root: bool,
|
|
||||||
scan_mode: str = "deep",
|
|
||||||
is_whitebox: bool = False,
|
|
||||||
interactive: bool = False,
|
|
||||||
chat_completions_tools: bool = False,
|
|
||||||
system_prompt_context: dict[str, Any] | None = None,
|
|
||||||
) -> SandboxAgent[Any]:
|
|
||||||
"""Build a SandboxAgent for either root or child use.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
chat_completions_tools: Wrap SDK custom tools as function tools
|
|
||||||
when the selected backend cannot accept Responses custom tools.
|
|
||||||
"""
|
|
||||||
instructions = render_system_prompt(
|
|
||||||
skills=skills,
|
|
||||||
scan_mode=scan_mode,
|
|
||||||
is_whitebox=is_whitebox,
|
|
||||||
is_root=is_root,
|
|
||||||
interactive=interactive,
|
|
||||||
system_prompt_context=system_prompt_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_root:
|
|
||||||
tools: list[Tool] = [*_BASE_TOOLS, finish_scan]
|
|
||||||
else:
|
|
||||||
tools = [*_BASE_TOOLS, agent_finish]
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Built %s agent '%s' (skills=%d, tools=%d, scan_mode=%s, whitebox=%s)",
|
|
||||||
"root" if is_root else "child",
|
|
||||||
name,
|
|
||||||
len(skills or []),
|
|
||||||
len(tools),
|
|
||||||
scan_mode,
|
|
||||||
is_whitebox,
|
|
||||||
)
|
|
||||||
|
|
||||||
return SandboxAgent(
|
|
||||||
name=name,
|
|
||||||
instructions=instructions,
|
|
||||||
tools=tools,
|
|
||||||
tool_use_behavior=_finish_tool_use_behavior,
|
|
||||||
model=None,
|
|
||||||
capabilities=[
|
|
||||||
Filesystem(
|
|
||||||
configure_tools=(
|
|
||||||
_configure_chat_completions_filesystem_tools if chat_completions_tools else None
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Shell(
|
|
||||||
configure_tools=_make_shell_configurator(
|
|
||||||
chat_completions=chat_completions_tools,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_child_factory(
|
|
||||||
*,
|
|
||||||
scan_mode: str = "deep",
|
|
||||||
is_whitebox: bool = False,
|
|
||||||
interactive: bool = False,
|
|
||||||
chat_completions_tools: bool = False,
|
|
||||||
system_prompt_context: dict[str, Any] | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""Return the runner-owned builder used by ``spawn_child_agent``.
|
|
||||||
|
|
||||||
Run-level arguments (``scan_mode``, ``is_whitebox``, etc.) are
|
|
||||||
captured in a closure so each child inherits scan-level configuration
|
|
||||||
without the graph tool knowing about runner internals.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _factory(*, name: str, skills: list[str]) -> SandboxAgent[Any]:
|
|
||||||
return build_strix_agent(
|
|
||||||
name=name,
|
|
||||||
skills=skills,
|
|
||||||
is_root=False,
|
|
||||||
scan_mode=scan_mode,
|
|
||||||
is_whitebox=is_whitebox,
|
|
||||||
interactive=interactive,
|
|
||||||
chat_completions_tools=chat_completions_tools,
|
|
||||||
system_prompt_context=system_prompt_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
return _factory
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
"""Jinja-based system-prompt renderer."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
||||||
|
|
||||||
from strix.skills import get_available_skills, load_skills
|
|
||||||
from strix.utils.resource_paths import get_strix_resource_path
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
_PROMPT_DIRNAME = "prompts"
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_skills(
|
|
||||||
*,
|
|
||||||
requested: list[str] | None,
|
|
||||||
scan_mode: str = "deep",
|
|
||||||
is_whitebox: bool = False,
|
|
||||||
is_root: bool = False,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Build the deduped, ordered skills list for the prompt render.
|
|
||||||
|
|
||||||
Order:
|
|
||||||
|
|
||||||
1. Whatever the caller asked for, in order.
|
|
||||||
2. ``scan_modes/<mode>`` (always).
|
|
||||||
3. ``tooling/agent_browser`` (always — every agent has shell + the
|
|
||||||
agent-browser CLI).
|
|
||||||
4. ``tooling/python`` (always — Python runs through ``exec_command``;
|
|
||||||
sandbox scripts can import ``caido_api`` for Caido automation).
|
|
||||||
5. ``coordination/root_agent`` for the root agent only — orchestration
|
|
||||||
guidance for delegating to specialist subagents.
|
|
||||||
6. Whitebox-specific skills if applicable.
|
|
||||||
"""
|
|
||||||
ordered: list[str] = list(requested or [])
|
|
||||||
ordered.append(f"scan_modes/{scan_mode}")
|
|
||||||
ordered.append("tooling/agent_browser")
|
|
||||||
ordered.append("tooling/python")
|
|
||||||
if is_root:
|
|
||||||
ordered.append("coordination/root_agent")
|
|
||||||
if is_whitebox:
|
|
||||||
ordered.append("coordination/source_aware_whitebox")
|
|
||||||
ordered.append("custom/source_aware_sast")
|
|
||||||
|
|
||||||
deduped: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for skill in ordered:
|
|
||||||
if skill and skill not in seen:
|
|
||||||
deduped.append(skill)
|
|
||||||
seen.add(skill)
|
|
||||||
return deduped
|
|
||||||
|
|
||||||
|
|
||||||
def render_system_prompt(
|
|
||||||
*,
|
|
||||||
skills: list[str] | None = None,
|
|
||||||
scan_mode: str = "deep",
|
|
||||||
is_whitebox: bool = False,
|
|
||||||
is_root: bool = False,
|
|
||||||
interactive: bool = False,
|
|
||||||
system_prompt_context: dict[str, Any] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Render the system prompt. Returns empty string on template failure."""
|
|
||||||
try:
|
|
||||||
prompt_dir = get_strix_resource_path("agents", _PROMPT_DIRNAME)
|
|
||||||
skills_dir = get_strix_resource_path("skills")
|
|
||||||
env = Environment(
|
|
||||||
loader=FileSystemLoader([prompt_dir, skills_dir]),
|
|
||||||
autoescape=select_autoescape(
|
|
||||||
enabled_extensions=(),
|
|
||||||
default_for_string=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
skills_to_load = _resolve_skills(
|
|
||||||
requested=skills,
|
|
||||||
scan_mode=scan_mode,
|
|
||||||
is_whitebox=is_whitebox,
|
|
||||||
is_root=is_root,
|
|
||||||
)
|
|
||||||
skill_content = load_skills(skills_to_load)
|
|
||||||
env.globals["get_skill"] = lambda name: skill_content.get(name, "")
|
|
||||||
|
|
||||||
rendered = env.get_template("system_prompt.jinja").render(
|
|
||||||
loaded_skill_names=list(skill_content.keys()),
|
|
||||||
available_skills=get_available_skills(),
|
|
||||||
interactive=interactive,
|
|
||||||
system_prompt_context=system_prompt_context or {},
|
|
||||||
**skill_content,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("render_system_prompt failed; returning empty prompt")
|
|
||||||
return ""
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
"render_system_prompt: scan_mode=%s root=%s whitebox=%s skills=%d prompt_len=%d",
|
|
||||||
scan_mode,
|
|
||||||
is_root,
|
|
||||||
is_whitebox,
|
|
||||||
len(skill_content),
|
|
||||||
len(rendered),
|
|
||||||
)
|
|
||||||
return str(rendered)
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_agent_id() -> str:
|
||||||
|
return f"agent_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentState(BaseModel):
|
||||||
|
agent_id: str = Field(default_factory=_generate_agent_id)
|
||||||
|
agent_name: str = "Strix Agent"
|
||||||
|
parent_id: str | None = None
|
||||||
|
sandbox_id: str | None = None
|
||||||
|
sandbox_token: str | None = None
|
||||||
|
sandbox_info: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
task: str = ""
|
||||||
|
iteration: int = 0
|
||||||
|
max_iterations: int = 300
|
||||||
|
completed: bool = False
|
||||||
|
stop_requested: bool = False
|
||||||
|
waiting_for_input: bool = False
|
||||||
|
llm_failed: bool = False
|
||||||
|
waiting_start_time: datetime | None = None
|
||||||
|
waiting_timeout: int = 600
|
||||||
|
final_result: dict[str, Any] | None = None
|
||||||
|
max_iterations_warning_sent: bool = False
|
||||||
|
|
||||||
|
messages: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
context: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
start_time: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||||
|
last_updated: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||||
|
|
||||||
|
actions_taken: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
observations: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
errors: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
def increment_iteration(self) -> None:
|
||||||
|
self.iteration += 1
|
||||||
|
self.last_updated = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
def add_message(
|
||||||
|
self, role: str, content: Any, thinking_blocks: list[dict[str, Any]] | None = None
|
||||||
|
) -> None:
|
||||||
|
message = {"role": role, "content": content}
|
||||||
|
if thinking_blocks:
|
||||||
|
message["thinking_blocks"] = thinking_blocks
|
||||||
|
self.messages.append(message)
|
||||||
|
self.last_updated = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
def add_action(self, action: dict[str, Any]) -> None:
|
||||||
|
self.actions_taken.append(
|
||||||
|
{
|
||||||
|
"iteration": self.iteration,
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"action": action,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_observation(self, observation: dict[str, Any]) -> None:
|
||||||
|
self.observations.append(
|
||||||
|
{
|
||||||
|
"iteration": self.iteration,
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"observation": observation,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_error(self, error: str) -> None:
|
||||||
|
self.errors.append(f"Iteration {self.iteration}: {error}")
|
||||||
|
self.last_updated = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
def update_context(self, key: str, value: Any) -> None:
|
||||||
|
self.context[key] = value
|
||||||
|
self.last_updated = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
def set_completed(self, final_result: dict[str, Any] | None = None) -> None:
|
||||||
|
self.completed = True
|
||||||
|
self.final_result = final_result
|
||||||
|
self.last_updated = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
def request_stop(self) -> None:
|
||||||
|
self.stop_requested = True
|
||||||
|
self.last_updated = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
def should_stop(self) -> bool:
|
||||||
|
return self.stop_requested or self.completed or self.has_reached_max_iterations()
|
||||||
|
|
||||||
|
def is_waiting_for_input(self) -> bool:
|
||||||
|
return self.waiting_for_input
|
||||||
|
|
||||||
|
def enter_waiting_state(self, llm_failed: bool = False) -> None:
|
||||||
|
self.waiting_for_input = True
|
||||||
|
self.waiting_start_time = datetime.now(UTC)
|
||||||
|
self.llm_failed = llm_failed
|
||||||
|
self.last_updated = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
def resume_from_waiting(self, new_task: str | None = None) -> None:
|
||||||
|
self.waiting_for_input = False
|
||||||
|
self.waiting_start_time = None
|
||||||
|
self.stop_requested = False
|
||||||
|
self.completed = False
|
||||||
|
self.llm_failed = False
|
||||||
|
if new_task:
|
||||||
|
self.task = new_task
|
||||||
|
self.last_updated = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
def has_reached_max_iterations(self) -> bool:
|
||||||
|
return self.iteration >= self.max_iterations
|
||||||
|
|
||||||
|
def is_approaching_max_iterations(self, threshold: float = 0.85) -> bool:
|
||||||
|
return self.iteration >= int(self.max_iterations * threshold)
|
||||||
|
|
||||||
|
def has_waiting_timeout(self) -> bool:
|
||||||
|
if self.waiting_timeout == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.waiting_for_input or not self.waiting_start_time:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.stop_requested
|
||||||
|
or self.llm_failed
|
||||||
|
or self.completed
|
||||||
|
or self.has_reached_max_iterations()
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
elapsed = (datetime.now(UTC) - self.waiting_start_time).total_seconds()
|
||||||
|
return elapsed > self.waiting_timeout
|
||||||
|
|
||||||
|
def has_empty_last_messages(self, count: int = 3) -> bool:
|
||||||
|
if len(self.messages) < count:
|
||||||
|
return False
|
||||||
|
|
||||||
|
last_messages = self.messages[-count:]
|
||||||
|
|
||||||
|
for message in last_messages:
|
||||||
|
content = message.get("content", "")
|
||||||
|
if isinstance(content, str) and content.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_conversation_history(self) -> list[dict[str, Any]]:
|
||||||
|
return self.messages
|
||||||
|
|
||||||
|
def get_execution_summary(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"agent_name": self.agent_name,
|
||||||
|
"parent_id": self.parent_id,
|
||||||
|
"sandbox_id": self.sandbox_id,
|
||||||
|
"sandbox_info": self.sandbox_info,
|
||||||
|
"task": self.task,
|
||||||
|
"iteration": self.iteration,
|
||||||
|
"max_iterations": self.max_iterations,
|
||||||
|
"completed": self.completed,
|
||||||
|
"final_result": self.final_result,
|
||||||
|
"start_time": self.start_time,
|
||||||
|
"last_updated": self.last_updated,
|
||||||
|
"total_actions": len(self.actions_taken),
|
||||||
|
"total_observations": len(self.observations),
|
||||||
|
"total_errors": len(self.errors),
|
||||||
|
"has_errors": len(self.errors) > 0,
|
||||||
|
"max_iterations_reached": self.has_reached_max_iterations() and not self.completed,
|
||||||
|
}
|
||||||
@@ -1,37 +1,12 @@
|
|||||||
"""Strix application settings.
|
from strix.config.config import (
|
||||||
|
Config,
|
||||||
Public surface:
|
apply_saved_config,
|
||||||
|
save_current_config,
|
||||||
- :class:`Settings` — composite model. Get via :func:`load_settings`.
|
|
||||||
- :class:`LlmSettings`, :class:`RuntimeSettings`, :class:`TelemetrySettings`,
|
|
||||||
:class:`IntegrationSettings` — sub-models, attribute-accessed off
|
|
||||||
``Settings``.
|
|
||||||
- :func:`load_settings` — memoized resolve (env > JSON file > defaults).
|
|
||||||
- :func:`apply_config_override` — switch the JSON source to a custom path.
|
|
||||||
- :func:`persist_current` — write currently-set env vars to the active file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from strix.config.loader import (
|
|
||||||
apply_config_override,
|
|
||||||
load_settings,
|
|
||||||
persist_current,
|
|
||||||
)
|
|
||||||
from strix.config.settings import (
|
|
||||||
IntegrationSettings,
|
|
||||||
LlmSettings,
|
|
||||||
RuntimeSettings,
|
|
||||||
Settings,
|
|
||||||
TelemetrySettings,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"IntegrationSettings",
|
"Config",
|
||||||
"LlmSettings",
|
"apply_saved_config",
|
||||||
"RuntimeSettings",
|
"save_current_config",
|
||||||
"Settings",
|
|
||||||
"TelemetrySettings",
|
|
||||||
"apply_config_override",
|
|
||||||
"load_settings",
|
|
||||||
"persist_current",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
STRIX_API_BASE = "https://models.strix.ai/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Configuration Manager for Strix."""
|
||||||
|
|
||||||
|
# LLM Configuration
|
||||||
|
strix_llm = None
|
||||||
|
llm_api_key = None
|
||||||
|
llm_api_base = None
|
||||||
|
openai_api_base = None
|
||||||
|
litellm_base_url = None
|
||||||
|
ollama_api_base = None
|
||||||
|
strix_reasoning_effort = "high"
|
||||||
|
strix_llm_max_retries = "5"
|
||||||
|
strix_memory_compressor_timeout = "30"
|
||||||
|
llm_timeout = "300"
|
||||||
|
_LLM_CANONICAL_NAMES = (
|
||||||
|
"strix_llm",
|
||||||
|
"llm_api_key",
|
||||||
|
"llm_api_base",
|
||||||
|
"openai_api_base",
|
||||||
|
"litellm_base_url",
|
||||||
|
"ollama_api_base",
|
||||||
|
"strix_reasoning_effort",
|
||||||
|
"strix_llm_max_retries",
|
||||||
|
"strix_memory_compressor_timeout",
|
||||||
|
"llm_timeout",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tool & Feature Configuration
|
||||||
|
perplexity_api_key = None
|
||||||
|
strix_disable_browser = "false"
|
||||||
|
|
||||||
|
# Runtime Configuration
|
||||||
|
strix_image = "ghcr.io/usestrix/strix-sandbox:0.1.13"
|
||||||
|
strix_runtime_backend = "docker"
|
||||||
|
strix_sandbox_execution_timeout = "120"
|
||||||
|
strix_sandbox_connect_timeout = "10"
|
||||||
|
|
||||||
|
# Telemetry
|
||||||
|
strix_telemetry = "1"
|
||||||
|
strix_otel_telemetry = None
|
||||||
|
strix_posthog_telemetry = None
|
||||||
|
traceloop_base_url = None
|
||||||
|
traceloop_api_key = None
|
||||||
|
traceloop_headers = None
|
||||||
|
|
||||||
|
# Config file override (set via --config CLI arg)
|
||||||
|
_config_file_override: Path | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _tracked_names(cls) -> list[str]:
|
||||||
|
return [
|
||||||
|
k
|
||||||
|
for k, v in vars(cls).items()
|
||||||
|
if not k.startswith("_") and k[0].islower() and (v is None or isinstance(v, str))
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tracked_vars(cls) -> list[str]:
|
||||||
|
return [name.upper() for name in cls._tracked_names()]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _llm_env_vars(cls) -> set[str]:
|
||||||
|
return {name.upper() for name in cls._LLM_CANONICAL_NAMES}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _llm_env_changed(cls, saved_env: dict[str, Any]) -> bool:
|
||||||
|
for var_name in cls._llm_env_vars():
|
||||||
|
current = os.getenv(var_name)
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
if saved_env.get(var_name) != current:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, name: str) -> str | None:
|
||||||
|
env_name = name.upper()
|
||||||
|
default = getattr(cls, name, None)
|
||||||
|
return os.getenv(env_name, default)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def config_dir(cls) -> Path:
|
||||||
|
return Path.home() / ".strix"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def config_file(cls) -> Path:
|
||||||
|
if cls._config_file_override is not None:
|
||||||
|
return cls._config_file_override
|
||||||
|
return cls.config_dir() / "cli-config.json"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls) -> dict[str, Any]:
|
||||||
|
path = cls.config_file()
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
data: dict[str, Any] = json.load(f)
|
||||||
|
return data
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def save(cls, config: dict[str, Any]) -> bool:
|
||||||
|
try:
|
||||||
|
cls.config_dir().mkdir(parents=True, exist_ok=True)
|
||||||
|
config_path = cls.config_dir() / "cli-config.json"
|
||||||
|
with config_path.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
config_path.chmod(0o600) # may fail on Windows
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def apply_saved(cls, force: bool = False) -> dict[str, str]:
|
||||||
|
saved = cls.load()
|
||||||
|
env_vars = saved.get("env", {})
|
||||||
|
if not isinstance(env_vars, dict):
|
||||||
|
env_vars = {}
|
||||||
|
cleared_vars = {
|
||||||
|
var_name
|
||||||
|
for var_name in cls.tracked_vars()
|
||||||
|
if var_name in os.environ and os.environ.get(var_name) == ""
|
||||||
|
}
|
||||||
|
if cleared_vars:
|
||||||
|
for var_name in cleared_vars:
|
||||||
|
env_vars.pop(var_name, None)
|
||||||
|
if cls._config_file_override is None:
|
||||||
|
cls.save({"env": env_vars})
|
||||||
|
if cls._llm_env_changed(env_vars):
|
||||||
|
for var_name in cls._llm_env_vars():
|
||||||
|
env_vars.pop(var_name, None)
|
||||||
|
if cls._config_file_override is None:
|
||||||
|
cls.save({"env": env_vars})
|
||||||
|
applied = {}
|
||||||
|
|
||||||
|
for var_name, var_value in env_vars.items():
|
||||||
|
if var_name in cls.tracked_vars() and (force or var_name not in os.environ):
|
||||||
|
os.environ[var_name] = var_value
|
||||||
|
applied[var_name] = var_value
|
||||||
|
|
||||||
|
return applied
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def capture_current(cls) -> dict[str, Any]:
|
||||||
|
env_vars = {}
|
||||||
|
for var_name in cls.tracked_vars():
|
||||||
|
value = os.getenv(var_name)
|
||||||
|
if value:
|
||||||
|
env_vars[var_name] = value
|
||||||
|
return {"env": env_vars}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def save_current(cls) -> bool:
|
||||||
|
existing = cls.load().get("env", {})
|
||||||
|
merged = dict(existing)
|
||||||
|
|
||||||
|
for var_name in cls.tracked_vars():
|
||||||
|
value = os.getenv(var_name)
|
||||||
|
if value is None:
|
||||||
|
pass
|
||||||
|
elif value == "":
|
||||||
|
merged.pop(var_name, None)
|
||||||
|
else:
|
||||||
|
merged[var_name] = value
|
||||||
|
|
||||||
|
return cls.save({"env": merged})
|
||||||
|
|
||||||
|
|
||||||
|
def apply_saved_config(force: bool = False) -> dict[str, str]:
|
||||||
|
return Config.apply_saved(force=force)
|
||||||
|
|
||||||
|
|
||||||
|
def save_current_config() -> bool:
|
||||||
|
return Config.save_current()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_llm_config() -> tuple[str | None, str | None, str | None]:
|
||||||
|
"""Resolve LLM model, api_key, and api_base based on STRIX_LLM prefix.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (model_name, api_key, api_base)
|
||||||
|
- model_name: Original model name (strix/ prefix preserved for display)
|
||||||
|
- api_key: LLM API key
|
||||||
|
- api_base: API base URL (auto-set to STRIX_API_BASE for strix/ models)
|
||||||
|
"""
|
||||||
|
model = Config.get("strix_llm")
|
||||||
|
if not model:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
api_key = Config.get("llm_api_key")
|
||||||
|
|
||||||
|
if model.startswith("strix/"):
|
||||||
|
api_base: str | None = STRIX_API_BASE
|
||||||
|
else:
|
||||||
|
api_base = (
|
||||||
|
Config.get("llm_api_base")
|
||||||
|
or Config.get("openai_api_base")
|
||||||
|
or Config.get("litellm_base_url")
|
||||||
|
or Config.get("ollama_api_base")
|
||||||
|
)
|
||||||
|
|
||||||
|
return model, api_key, api_base
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
"""Settings loader, override switch, and disk persistence."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from pydantic import AliasChoices, BaseModel
|
|
||||||
|
|
||||||
from strix.config.settings import Settings
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pydantic.fields import FieldInfo
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_PATH: Path = Path.home() / ".strix" / "cli-config.json"
|
|
||||||
_override: Path | None = None
|
|
||||||
_cached: Settings | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def load_settings() -> Settings:
|
|
||||||
"""Resolve settings from env + JSON file + defaults. Memoized.
|
|
||||||
|
|
||||||
Precedence: env vars win, then the JSON file, then field defaults.
|
|
||||||
"""
|
|
||||||
global _cached # noqa: PLW0603
|
|
||||||
if _cached is None:
|
|
||||||
source_path = _override or _DEFAULT_PATH
|
|
||||||
init_kwargs: dict[str, Any] = _read_json_overrides(source_path)
|
|
||||||
_cached = Settings(**init_kwargs)
|
|
||||||
logger.debug(
|
|
||||||
"load_settings: resolved (override=%s, file_used=%s, json_keys=%d)",
|
|
||||||
_override is not None,
|
|
||||||
source_path.exists(),
|
|
||||||
sum(len(v) for v in init_kwargs.values()),
|
|
||||||
)
|
|
||||||
return _cached
|
|
||||||
|
|
||||||
|
|
||||||
def apply_config_override(path: Path) -> None:
|
|
||||||
"""Switch the JSON source to ``path`` and invalidate the cache."""
|
|
||||||
global _override, _cached # noqa: PLW0603
|
|
||||||
_override = path
|
|
||||||
_cached = None
|
|
||||||
logger.info("config override applied: %s", path)
|
|
||||||
|
|
||||||
|
|
||||||
def persist_current() -> None:
|
|
||||||
"""Write currently-set env vars to the active config file (0o600)."""
|
|
||||||
s = load_settings()
|
|
||||||
target = _override or _DEFAULT_PATH
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
env_block: dict[str, str] = {}
|
|
||||||
for sub_name in s.model_fields:
|
|
||||||
sub_model = getattr(s, sub_name)
|
|
||||||
if not isinstance(sub_model, BaseModel):
|
|
||||||
continue
|
|
||||||
for finfo in type(sub_model).model_fields.values():
|
|
||||||
for alias in _aliases_for(finfo):
|
|
||||||
value = os.environ.get(alias.upper())
|
|
||||||
if value:
|
|
||||||
env_block[alias.upper()] = value
|
|
||||||
break
|
|
||||||
|
|
||||||
target.write_text(json.dumps({"env": env_block}, indent=2), encoding="utf-8")
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
target.chmod(0o600)
|
|
||||||
|
|
||||||
|
|
||||||
def _aliases_for(finfo: FieldInfo) -> list[str]:
|
|
||||||
"""Collect every env-var name that should populate ``finfo``."""
|
|
||||||
aliases: list[str] = []
|
|
||||||
if finfo.alias:
|
|
||||||
aliases.append(finfo.alias)
|
|
||||||
va = finfo.validation_alias
|
|
||||||
if isinstance(va, AliasChoices):
|
|
||||||
aliases.extend(c for c in va.choices if isinstance(c, str))
|
|
||||||
elif isinstance(va, str):
|
|
||||||
aliases.append(va)
|
|
||||||
return aliases
|
|
||||||
|
|
||||||
|
|
||||||
def _read_json_overrides(path: Path) -> dict[str, dict[str, Any]]:
|
|
||||||
"""Read ``{"env": {...}}`` from ``path`` and remap to nested kwargs.
|
|
||||||
|
|
||||||
Only includes keys whose env var is NOT already set, so env always
|
|
||||||
wins over the persisted file.
|
|
||||||
"""
|
|
||||||
if not path.exists():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
return {}
|
|
||||||
env_block = data.get("env", {}) if isinstance(data, dict) else {}
|
|
||||||
if not isinstance(env_block, dict):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
env_block_upper = {str(k).upper(): v for k, v in env_block.items()}
|
|
||||||
|
|
||||||
nested: dict[str, dict[str, Any]] = {}
|
|
||||||
for sub_name, sub_finfo in Settings.model_fields.items():
|
|
||||||
sub_cls = sub_finfo.annotation
|
|
||||||
if not (isinstance(sub_cls, type) and issubclass(sub_cls, BaseModel)):
|
|
||||||
continue
|
|
||||||
sub_data: dict[str, Any] = {}
|
|
||||||
for fname, finfo in sub_cls.model_fields.items():
|
|
||||||
for alias in _aliases_for(finfo):
|
|
||||||
key = alias.upper()
|
|
||||||
if key in os.environ:
|
|
||||||
break # env wins; skip JSON for this field
|
|
||||||
if key in env_block_upper:
|
|
||||||
sub_data[fname] = env_block_upper[key]
|
|
||||||
break
|
|
||||||
if sub_data:
|
|
||||||
nested[sub_name] = sub_data
|
|
||||||
return nested
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
"""SDK model configuration helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from agents import set_default_openai_api, set_default_openai_key, set_tracing_disabled
|
|
||||||
from agents.models.multi_provider import MultiProvider
|
|
||||||
from agents.retry import (
|
|
||||||
ModelRetryBackoffSettings,
|
|
||||||
ModelRetrySettings,
|
|
||||||
retry_policies,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from agents.models.interface import ModelProvider
|
|
||||||
|
|
||||||
from strix.config.settings import Settings
|
|
||||||
|
|
||||||
|
|
||||||
class StrixProvider(MultiProvider):
|
|
||||||
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
|
|
||||||
so users type ``deepseek/deepseek-chat`` rather than
|
|
||||||
``litellm/deepseek/deepseek-chat``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _resolve_prefixed_model(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
original_model_name: str,
|
|
||||||
prefix: str,
|
|
||||||
stripped_model_name: str | None,
|
|
||||||
) -> tuple[ModelProvider, str | None]:
|
|
||||||
if prefix in {"openai", "litellm", "any-llm"}:
|
|
||||||
return super()._resolve_prefixed_model(
|
|
||||||
original_model_name=original_model_name,
|
|
||||||
prefix=prefix,
|
|
||||||
stripped_model_name=stripped_model_name,
|
|
||||||
)
|
|
||||||
if prefix == "ollama" and stripped_model_name:
|
|
||||||
return self._get_fallback_provider("litellm"), f"ollama_chat/{stripped_model_name}"
|
|
||||||
return self._get_fallback_provider("litellm"), original_model_name
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
|
||||||
max_retries=5,
|
|
||||||
backoff=ModelRetryBackoffSettings(
|
|
||||||
initial_delay=2.0,
|
|
||||||
max_delay=90.0,
|
|
||||||
multiplier=2.0,
|
|
||||||
jitter=False,
|
|
||||||
),
|
|
||||||
policy=retry_policies.any(
|
|
||||||
retry_policies.provider_suggested(),
|
|
||||||
retry_policies.network_error(),
|
|
||||||
retry_policies.http_status((429, 500, 502, 503, 504)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def configure_sdk_model_defaults(settings: Settings) -> None:
|
|
||||||
"""Apply Strix config to SDK-native defaults."""
|
|
||||||
llm = settings.llm
|
|
||||||
set_tracing_disabled(True)
|
|
||||||
_configure_litellm_compatibility()
|
|
||||||
if llm.api_key:
|
|
||||||
set_default_openai_key(llm.api_key, use_for_tracing=False)
|
|
||||||
_configure_litellm_default("api_key", llm.api_key)
|
|
||||||
_mirror_api_key_to_provider_env(llm.model, llm.api_key)
|
|
||||||
if llm.api_base:
|
|
||||||
os.environ["OPENAI_BASE_URL"] = llm.api_base
|
|
||||||
_configure_litellm_default("api_base", llm.api_base)
|
|
||||||
set_default_openai_api("chat_completions")
|
|
||||||
else:
|
|
||||||
set_default_openai_api("responses")
|
|
||||||
|
|
||||||
|
|
||||||
def _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None:
|
|
||||||
if not model_name:
|
|
||||||
return
|
|
||||||
import litellm
|
|
||||||
|
|
||||||
name = model_name.strip()
|
|
||||||
for prefix in ("litellm/", "any-llm/"):
|
|
||||||
if name.lower().startswith(prefix):
|
|
||||||
name = name[len(prefix) :]
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
report = litellm.validate_environment(model=name.lower())
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
return
|
|
||||||
for env_key in report.get("missing_keys") or []:
|
|
||||||
if env_key.endswith("_API_KEY"):
|
|
||||||
os.environ.setdefault(env_key, api_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_litellm_compatibility() -> None:
|
|
||||||
"""Enable LiteLLM's permissive param handling and disable its callbacks."""
|
|
||||||
import litellm
|
|
||||||
|
|
||||||
litellm.drop_params = True
|
|
||||||
litellm.modify_params = True
|
|
||||||
litellm.turn_off_message_logging = True
|
|
||||||
litellm.disable_streaming_logging = True
|
|
||||||
litellm.suppress_debug_info = True
|
|
||||||
|
|
||||||
_register_litellm_cost_callback()
|
|
||||||
|
|
||||||
|
|
||||||
def _register_litellm_cost_callback() -> None:
|
|
||||||
import litellm
|
|
||||||
|
|
||||||
from strix.report.state import litellm_cost_callback
|
|
||||||
|
|
||||||
for bucket_name in ("success_callback", "_async_success_callback"):
|
|
||||||
bucket = getattr(litellm, bucket_name, None)
|
|
||||||
if not isinstance(bucket, list):
|
|
||||||
continue
|
|
||||||
if litellm_cost_callback in bucket:
|
|
||||||
continue
|
|
||||||
bucket.append(litellm_cost_callback)
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_litellm_default(name: str, value: str) -> None:
|
|
||||||
"""Set LiteLLM's module-level defaults without adding a provider wrapper."""
|
|
||||||
import litellm
|
|
||||||
|
|
||||||
setattr(litellm, name, value)
|
|
||||||
|
|
||||||
|
|
||||||
def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bool:
|
|
||||||
"""Return whether the resolved SDK route can only receive JSON function tools."""
|
|
||||||
model = model_name.strip().lower()
|
|
||||||
if "/" in model and not model.startswith("openai/"):
|
|
||||||
return True
|
|
||||||
if settings.llm.api_base:
|
|
||||||
return True
|
|
||||||
return not model_supports_reasoning(model_name)
|
|
||||||
|
|
||||||
|
|
||||||
def model_supports_reasoning(model_name: str) -> bool:
|
|
||||||
import litellm
|
|
||||||
|
|
||||||
name = model_name.strip().lower()
|
|
||||||
for prefix in ("litellm/", "any-llm/", "openai/"):
|
|
||||||
if name.startswith(prefix):
|
|
||||||
name = name[len(prefix) :]
|
|
||||||
break
|
|
||||||
entry = litellm.model_cost.get(name)
|
|
||||||
if entry is None and "/" in name:
|
|
||||||
entry = litellm.model_cost.get(name.rsplit("/", 1)[1])
|
|
||||||
return bool(entry and entry.get("supports_reasoning"))
|
|
||||||
|
|
||||||
|
|
||||||
def is_known_openai_bare_model(model_name: str) -> bool:
|
|
||||||
import litellm
|
|
||||||
|
|
||||||
name = model_name.strip().lower()
|
|
||||||
if not name or "/" in name:
|
|
||||||
return False
|
|
||||||
entry = litellm.model_cost.get(name)
|
|
||||||
return bool(entry and entry.get("litellm_provider") == "openai")
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""Strix application settings — pydantic-settings powered."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import AliasChoices, Field
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh"]
|
|
||||||
|
|
||||||
_BASE_CONFIG = SettingsConfigDict(
|
|
||||||
case_sensitive=False,
|
|
||||||
populate_by_name=True,
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LlmSettings(BaseSettings):
|
|
||||||
model_config = _BASE_CONFIG
|
|
||||||
|
|
||||||
model: str | None = Field(default=None, alias="STRIX_LLM")
|
|
||||||
api_key: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
validation_alias=AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"),
|
|
||||||
)
|
|
||||||
api_base: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
validation_alias=AliasChoices(
|
|
||||||
"LLM_API_BASE",
|
|
||||||
"OPENAI_API_BASE",
|
|
||||||
"OPENAI_BASE_URL",
|
|
||||||
"LITELLM_BASE_URL",
|
|
||||||
"OLLAMA_API_BASE",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT")
|
|
||||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeSettings(BaseSettings):
|
|
||||||
model_config = _BASE_CONFIG
|
|
||||||
|
|
||||||
image: str = Field(
|
|
||||||
default="ghcr.io/usestrix/strix-sandbox:1.0.0",
|
|
||||||
alias="STRIX_IMAGE",
|
|
||||||
)
|
|
||||||
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
|
|
||||||
# Hard cap on a local target's size before we refuse to stream it into the
|
|
||||||
# sandbox file-by-file (the SDK copies every file individually, which stalls
|
|
||||||
# on large repos). Above this, the user must bind-mount via ``--mount``.
|
|
||||||
# Set to 0 (or less) to disable the pre-flight check entirely.
|
|
||||||
max_local_copy_mb: int = Field(default=1024, alias="STRIX_MAX_LOCAL_COPY_MB")
|
|
||||||
|
|
||||||
|
|
||||||
class TelemetrySettings(BaseSettings):
|
|
||||||
model_config = _BASE_CONFIG
|
|
||||||
|
|
||||||
enabled: bool = Field(default=True, alias="STRIX_TELEMETRY")
|
|
||||||
|
|
||||||
|
|
||||||
class IntegrationSettings(BaseSettings):
|
|
||||||
model_config = _BASE_CONFIG
|
|
||||||
|
|
||||||
perplexity_api_key: str | None = Field(default=None, alias="PERPLEXITY_API_KEY")
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
model_config = _BASE_CONFIG
|
|
||||||
|
|
||||||
llm: LlmSettings = Field(default_factory=LlmSettings)
|
|
||||||
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|
|
||||||
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
|
|
||||||
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Strix scan runtime core."""
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
"""SDK-native state for Strix's addressable agent graph."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import tempfile
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from agents.items import TResponseInputItem
|
|
||||||
from agents.memory import Session
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class AgentRuntime:
|
|
||||||
session: Session | None = None
|
|
||||||
task: asyncio.Task[Any] | None = None
|
|
||||||
stream: Any | None = None
|
|
||||||
interrupt_on_message: bool = False
|
|
||||||
wake: asyncio.Event = field(default_factory=asyncio.Event)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentCoordinator:
|
|
||||||
"""Single owner for graph state, SDK runtimes, messages, and resume snapshots."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.statuses: dict[str, Status] = {}
|
|
||||||
self.parent_of: dict[str, str | None] = {}
|
|
||||||
self.names: dict[str, str] = {}
|
|
||||||
self.metadata: dict[str, dict[str, Any]] = {}
|
|
||||||
self.pending_counts: dict[str, int] = {}
|
|
||||||
self.runtimes: dict[str, AgentRuntime] = {}
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
self._snapshot_path: Path | None = None
|
|
||||||
self.is_shutting_down = False
|
|
||||||
self._budget_stopped = False
|
|
||||||
|
|
||||||
def set_snapshot_path(self, path: Path) -> None:
|
|
||||||
self._snapshot_path = path
|
|
||||||
|
|
||||||
def mark_shutting_down(self) -> None:
|
|
||||||
self.is_shutting_down = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def budget_stopped(self) -> bool:
|
|
||||||
return self._budget_stopped
|
|
||||||
|
|
||||||
async def trigger_budget_stop(self) -> None:
|
|
||||||
"""Signal a scan-wide budget stop and wake every parked agent so it exits."""
|
|
||||||
async with self._lock:
|
|
||||||
self._budget_stopped = True
|
|
||||||
for runtime in self.runtimes.values():
|
|
||||||
runtime.wake.set()
|
|
||||||
|
|
||||||
async def register(
|
|
||||||
self,
|
|
||||||
agent_id: str,
|
|
||||||
name: str,
|
|
||||||
parent_id: str | None,
|
|
||||||
*,
|
|
||||||
task: str | None = None,
|
|
||||||
skills: list[str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
self.statuses[agent_id] = "running"
|
|
||||||
self.parent_of[agent_id] = parent_id
|
|
||||||
self.names[agent_id] = name
|
|
||||||
self.pending_counts.setdefault(agent_id, 0)
|
|
||||||
self.metadata[agent_id] = {
|
|
||||||
"task": task or "",
|
|
||||||
"skills": list(skills or []),
|
|
||||||
}
|
|
||||||
self.runtimes.setdefault(agent_id, AgentRuntime())
|
|
||||||
logger.info("agent.register %s (%s) parent=%s", agent_id, name, parent_id or "-")
|
|
||||||
await self._maybe_snapshot()
|
|
||||||
|
|
||||||
async def attach_runtime(
|
|
||||||
self,
|
|
||||||
agent_id: str,
|
|
||||||
*,
|
|
||||||
session: Session | None = None,
|
|
||||||
task: asyncio.Task[Any] | None = None,
|
|
||||||
interrupt_on_message: bool | None = None,
|
|
||||||
) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
|
|
||||||
if session is not None:
|
|
||||||
runtime.session = session
|
|
||||||
if task is not None:
|
|
||||||
runtime.task = task
|
|
||||||
if interrupt_on_message is not None:
|
|
||||||
runtime.interrupt_on_message = interrupt_on_message
|
|
||||||
|
|
||||||
async def mark_running(self, agent_id: str) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
if agent_id in self.statuses:
|
|
||||||
self.statuses[agent_id] = "running"
|
|
||||||
await self._maybe_snapshot()
|
|
||||||
|
|
||||||
async def park_waiting(self, agent_id: str) -> None:
|
|
||||||
await self.set_status(agent_id, "waiting")
|
|
||||||
|
|
||||||
async def set_status(self, agent_id: str, status: Status | str) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
if agent_id not in self.statuses:
|
|
||||||
return
|
|
||||||
self.statuses[agent_id] = status # type: ignore[assignment]
|
|
||||||
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
|
|
||||||
runtime.wake.set()
|
|
||||||
logger.info("agent.status %s=%s", agent_id, status)
|
|
||||||
await self._maybe_snapshot()
|
|
||||||
|
|
||||||
async def send(self, target_agent_id: str, message: dict[str, Any]) -> bool:
|
|
||||||
"""Deliver a user/peer message by appending it to the target SDK session."""
|
|
||||||
async with self._lock:
|
|
||||||
if target_agent_id not in self.statuses:
|
|
||||||
logger.debug("agent.send dropped unknown target=%s", target_agent_id)
|
|
||||||
return False
|
|
||||||
runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime())
|
|
||||||
session = runtime.session
|
|
||||||
stream = runtime.stream
|
|
||||||
interrupt = runtime.interrupt_on_message
|
|
||||||
if session is None:
|
|
||||||
logger.warning(
|
|
||||||
"agent.send dropped target=%s because its SDK session is not attached",
|
|
||||||
target_agent_id,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
await session.add_items([self._message_to_session_item(message)])
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"agent.send failed to append to SDK session target=%s",
|
|
||||||
target_agent_id,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
async with self._lock:
|
|
||||||
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
|
|
||||||
self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set()
|
|
||||||
if stream is not None and interrupt:
|
|
||||||
stream.cancel(mode="immediate")
|
|
||||||
await self._maybe_snapshot()
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def wait_for_message(self, agent_id: str) -> None:
|
|
||||||
while True:
|
|
||||||
async with self._lock:
|
|
||||||
if self._budget_stopped or self.pending_counts.get(agent_id, 0) > 0:
|
|
||||||
return
|
|
||||||
wake = self.runtimes.setdefault(agent_id, AgentRuntime()).wake
|
|
||||||
wake.clear()
|
|
||||||
await wake.wait()
|
|
||||||
|
|
||||||
async def consume_pending(
|
|
||||||
self,
|
|
||||||
agent_id: str,
|
|
||||||
*,
|
|
||||||
include_items: bool = False,
|
|
||||||
) -> tuple[int, list[Any]]:
|
|
||||||
async with self._lock:
|
|
||||||
count = self.pending_counts.get(agent_id, 0)
|
|
||||||
self.pending_counts[agent_id] = 0
|
|
||||||
session = self.runtimes.get(agent_id, AgentRuntime()).session
|
|
||||||
if count <= 0:
|
|
||||||
return 0, []
|
|
||||||
await self._maybe_snapshot()
|
|
||||||
if not include_items or session is None:
|
|
||||||
return count, []
|
|
||||||
items = await session.get_items()
|
|
||||||
return count, list(items[-count:])
|
|
||||||
|
|
||||||
async def request_stop(self, agent_id: str) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
if agent_id not in self.statuses:
|
|
||||||
return
|
|
||||||
self.statuses[agent_id] = "stopped"
|
|
||||||
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
|
|
||||||
runtime.wake.set()
|
|
||||||
stream = runtime.stream
|
|
||||||
if stream is not None:
|
|
||||||
stream.cancel(mode="after_turn")
|
|
||||||
await self._maybe_snapshot()
|
|
||||||
|
|
||||||
async def cancel_descendants(self, agent_id: str) -> None:
|
|
||||||
tasks = []
|
|
||||||
async with self._lock:
|
|
||||||
for aid in reversed(self._subtree_order_locked(agent_id)):
|
|
||||||
task = self.runtimes.get(aid, AgentRuntime()).task
|
|
||||||
if task is not None and not task.done():
|
|
||||||
tasks.append(task)
|
|
||||||
for task in tasks:
|
|
||||||
task.cancel()
|
|
||||||
if tasks:
|
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
async def cancel_descendants_graceful(self, agent_id: str) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
order = self._subtree_order_locked(agent_id)
|
|
||||||
for aid in reversed(order):
|
|
||||||
await self.request_stop(aid)
|
|
||||||
await self._maybe_snapshot()
|
|
||||||
|
|
||||||
async def attach_stream(
|
|
||||||
self,
|
|
||||||
agent_id: str,
|
|
||||||
stream: Any,
|
|
||||||
) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
self.runtimes.setdefault(agent_id, AgentRuntime()).stream = stream
|
|
||||||
|
|
||||||
async def detach_stream(
|
|
||||||
self,
|
|
||||||
agent_id: str,
|
|
||||||
stream: Any,
|
|
||||||
) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
|
|
||||||
if runtime.stream is stream:
|
|
||||||
runtime.stream = None
|
|
||||||
|
|
||||||
async def active_agents_except(self, agent_id: str) -> list[dict[str, Any]]:
|
|
||||||
async with self._lock:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"agent_id": aid,
|
|
||||||
"name": self.names.get(aid, aid),
|
|
||||||
"status": status,
|
|
||||||
"parent_id": self.parent_of.get(aid),
|
|
||||||
}
|
|
||||||
for aid, status in self.statuses.items()
|
|
||||||
if aid != agent_id and status in {"running", "waiting"}
|
|
||||||
]
|
|
||||||
|
|
||||||
async def graph_snapshot(
|
|
||||||
self,
|
|
||||||
) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str]]:
|
|
||||||
async with self._lock:
|
|
||||||
return dict(self.parent_of), dict(self.statuses), dict(self.names)
|
|
||||||
|
|
||||||
def _message_to_session_item(self, message: dict[str, Any]) -> TResponseInputItem:
|
|
||||||
sender = str(message.get("from", "unknown"))
|
|
||||||
content = str(message.get("content", ""))
|
|
||||||
if sender == "user":
|
|
||||||
return cast("TResponseInputItem", {"role": "user", "content": content})
|
|
||||||
sender_name = self.names.get(sender, sender)
|
|
||||||
msg_type = message.get("type", "information")
|
|
||||||
priority = message.get("priority", "normal")
|
|
||||||
return cast(
|
|
||||||
"TResponseInputItem",
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": (
|
|
||||||
f"[Message from {sender_name} ({sender}) | type={msg_type} "
|
|
||||||
f"| priority={priority}]\n{content}"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _subtree_order_locked(self, agent_id: str) -> list[str]:
|
|
||||||
queue = [agent_id]
|
|
||||||
order: list[str] = []
|
|
||||||
while queue:
|
|
||||||
aid = queue.pop()
|
|
||||||
order.append(aid)
|
|
||||||
queue.extend(child for child, parent in self.parent_of.items() if parent == aid)
|
|
||||||
return order
|
|
||||||
|
|
||||||
async def snapshot(self) -> dict[str, Any]:
|
|
||||||
async with self._lock:
|
|
||||||
return {
|
|
||||||
"statuses": dict(self.statuses),
|
|
||||||
"parent_of": dict(self.parent_of),
|
|
||||||
"names": dict(self.names),
|
|
||||||
"metadata": {aid: dict(md) for aid, md in self.metadata.items()},
|
|
||||||
"pending_counts": dict(self.pending_counts),
|
|
||||||
}
|
|
||||||
|
|
||||||
async def restore(self, snap: dict[str, Any]) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
self.statuses = dict(snap.get("statuses", {}))
|
|
||||||
self.parent_of = dict(snap.get("parent_of", {}))
|
|
||||||
self.names = dict(snap.get("names", {}))
|
|
||||||
self.metadata = {aid: dict(md) for aid, md in snap.get("metadata", {}).items()}
|
|
||||||
self.pending_counts = dict(snap.get("pending_counts", {}))
|
|
||||||
for aid in self.statuses:
|
|
||||||
self.runtimes.setdefault(aid, AgentRuntime())
|
|
||||||
|
|
||||||
async def _maybe_snapshot(self) -> None:
|
|
||||||
path = self._snapshot_path
|
|
||||||
if path is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
data = await self.snapshot()
|
|
||||||
payload = json.dumps(data, ensure_ascii=False, default=str)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode="w",
|
|
||||||
encoding="utf-8",
|
|
||||||
dir=str(path.parent),
|
|
||||||
prefix=f".{path.name}.",
|
|
||||||
suffix=".tmp",
|
|
||||||
delete=False,
|
|
||||||
) as tmp:
|
|
||||||
tmp.write(payload)
|
|
||||||
tmp_path = Path(tmp.name)
|
|
||||||
tmp_path.replace(path)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("coordinator snapshot to %s failed", path)
|
|
||||||
|
|
||||||
|
|
||||||
def coordinator_from_context(ctx: dict[str, Any]) -> AgentCoordinator | None:
|
|
||||||
coordinator = ctx.get("coordinator")
|
|
||||||
return coordinator if isinstance(coordinator, AgentCoordinator) else None
|
|
||||||
@@ -1,575 +0,0 @@
|
|||||||
"""Execution loop for addressable SDK-backed Strix agents."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
|
||||||
|
|
||||||
from agents import RunConfig, Runner
|
|
||||||
from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError
|
|
||||||
from agents.sandbox.errors import ExecTransportError
|
|
||||||
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
|
|
||||||
from openai import APIError
|
|
||||||
|
|
||||||
from strix.core.hooks import BudgetExceededError
|
|
||||||
from strix.core.inputs import child_initial_input
|
|
||||||
from strix.core.sessions import open_agent_session, strip_all_images_from_session
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from agents.items import TResponseInputItem
|
|
||||||
from agents.lifecycle import RunHooks
|
|
||||||
from agents.memory import Session, SQLiteSession
|
|
||||||
from agents.result import RunResultBase
|
|
||||||
|
|
||||||
from strix.core.agents import AgentCoordinator, Status
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
StreamEventSink = Callable[[str, Any], None]
|
|
||||||
|
|
||||||
_INPUT_REJECTION_CODES = frozenset({400, 404, 422})
|
|
||||||
|
|
||||||
|
|
||||||
async def run_agent_loop(
|
|
||||||
*,
|
|
||||||
agent: Any,
|
|
||||||
initial_input: Any,
|
|
||||||
run_config: RunConfig,
|
|
||||||
context: dict[str, Any],
|
|
||||||
max_turns: int,
|
|
||||||
coordinator: AgentCoordinator,
|
|
||||||
agent_id: str,
|
|
||||||
interactive: bool,
|
|
||||||
session: Session | None = None,
|
|
||||||
start_parked: bool = False,
|
|
||||||
event_sink: StreamEventSink | None = None,
|
|
||||||
hooks: RunHooks[dict[str, Any]] | None = None,
|
|
||||||
) -> RunResultBase | None:
|
|
||||||
await coordinator.attach_runtime(
|
|
||||||
agent_id,
|
|
||||||
session=session,
|
|
||||||
interrupt_on_message=interactive,
|
|
||||||
)
|
|
||||||
result: RunResultBase | None = None
|
|
||||||
|
|
||||||
if not (start_parked and interactive):
|
|
||||||
if interactive:
|
|
||||||
result = await _run_cycle(
|
|
||||||
agent,
|
|
||||||
coordinator,
|
|
||||||
agent_id,
|
|
||||||
input_data=initial_input,
|
|
||||||
run_config=run_config,
|
|
||||||
context=context,
|
|
||||||
max_turns=max_turns,
|
|
||||||
session=session,
|
|
||||||
interactive=interactive,
|
|
||||||
event_sink=event_sink,
|
|
||||||
hooks=hooks,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = await _run_noninteractive_until_lifecycle(
|
|
||||||
agent,
|
|
||||||
coordinator,
|
|
||||||
agent_id,
|
|
||||||
initial_input=initial_input,
|
|
||||||
run_config=run_config,
|
|
||||||
context=context,
|
|
||||||
max_turns=max_turns,
|
|
||||||
session=session,
|
|
||||||
event_sink=event_sink,
|
|
||||||
hooks=hooks,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not interactive:
|
|
||||||
return result
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await coordinator.wait_for_message(agent_id)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return result
|
|
||||||
|
|
||||||
if coordinator.budget_stopped:
|
|
||||||
await coordinator.set_status(agent_id, "stopped")
|
|
||||||
raise BudgetExceededError("scan budget reached")
|
|
||||||
|
|
||||||
await coordinator.consume_pending(agent_id)
|
|
||||||
result = await _run_cycle(
|
|
||||||
agent,
|
|
||||||
coordinator,
|
|
||||||
agent_id,
|
|
||||||
input_data=[],
|
|
||||||
run_config=run_config,
|
|
||||||
context=context,
|
|
||||||
max_turns=max_turns,
|
|
||||||
session=session,
|
|
||||||
interactive=interactive,
|
|
||||||
event_sink=event_sink,
|
|
||||||
hooks=hooks,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def spawn_child_agent(
|
|
||||||
*,
|
|
||||||
coordinator: AgentCoordinator,
|
|
||||||
factory: Any,
|
|
||||||
agents_db_path: Path,
|
|
||||||
sessions_to_close: list[SQLiteSession],
|
|
||||||
run_config: RunConfig,
|
|
||||||
max_turns: int,
|
|
||||||
interactive: bool,
|
|
||||||
parent_ctx: dict[str, Any],
|
|
||||||
name: str,
|
|
||||||
task: str,
|
|
||||||
skills: list[str],
|
|
||||||
parent_history: list[Any],
|
|
||||||
event_sink: StreamEventSink | None = None,
|
|
||||||
hooks: RunHooks[dict[str, Any]] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
parent_id = parent_ctx.get("agent_id")
|
|
||||||
if not isinstance(parent_id, str):
|
|
||||||
raise TypeError("Parent agent_id missing from context")
|
|
||||||
|
|
||||||
child_id = uuid.uuid4().hex[:8]
|
|
||||||
child_agent = factory(name=name, skills=skills)
|
|
||||||
await coordinator.register(
|
|
||||||
child_id,
|
|
||||||
name,
|
|
||||||
parent_id,
|
|
||||||
task=task,
|
|
||||||
skills=skills,
|
|
||||||
)
|
|
||||||
|
|
||||||
await _start_child_runner(
|
|
||||||
parent_ctx=parent_ctx,
|
|
||||||
coordinator=coordinator,
|
|
||||||
agents_db_path=agents_db_path,
|
|
||||||
sessions_to_close=sessions_to_close,
|
|
||||||
run_config=run_config,
|
|
||||||
max_turns=max_turns,
|
|
||||||
interactive=interactive,
|
|
||||||
child_agent=child_agent,
|
|
||||||
child_id=child_id,
|
|
||||||
name=name,
|
|
||||||
parent_id=parent_id,
|
|
||||||
task=task,
|
|
||||||
initial_input=child_initial_input(
|
|
||||||
name=name,
|
|
||||||
child_id=child_id,
|
|
||||||
parent_id=parent_id,
|
|
||||||
task=task,
|
|
||||||
parent_history=parent_history,
|
|
||||||
),
|
|
||||||
event_sink=event_sink,
|
|
||||||
hooks=hooks,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"agent_id": child_id,
|
|
||||||
"name": name,
|
|
||||||
"parent_id": parent_id,
|
|
||||||
"message": f"Spawned '{name}' ({child_id}) running in parallel.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def respawn_subagents(
|
|
||||||
*,
|
|
||||||
coordinator: AgentCoordinator,
|
|
||||||
factory: Any,
|
|
||||||
agents_db_path: Path,
|
|
||||||
sessions_to_close: list[SQLiteSession],
|
|
||||||
run_config: RunConfig,
|
|
||||||
max_turns: int,
|
|
||||||
interactive: bool,
|
|
||||||
parent_ctx: dict[str, Any],
|
|
||||||
root_id: str,
|
|
||||||
event_sink: StreamEventSink | None = None,
|
|
||||||
hooks: RunHooks[dict[str, Any]] | None = None,
|
|
||||||
) -> None:
|
|
||||||
async with coordinator._lock:
|
|
||||||
agents_snapshot = [
|
|
||||||
(aid, status, dict(coordinator.metadata.get(aid, {})))
|
|
||||||
for aid, status in coordinator.statuses.items()
|
|
||||||
]
|
|
||||||
candidates: list[tuple[str, str, str | None, dict[str, Any]]] = []
|
|
||||||
for aid, status, md in agents_snapshot:
|
|
||||||
if not interactive and status not in {"running", "waiting"}:
|
|
||||||
continue
|
|
||||||
if coordinator.parent_of.get(aid) is None or aid == root_id:
|
|
||||||
continue
|
|
||||||
md["_restored_status"] = status
|
|
||||||
candidates.append(
|
|
||||||
(
|
|
||||||
aid,
|
|
||||||
coordinator.names.get(aid, aid),
|
|
||||||
coordinator.parent_of.get(aid),
|
|
||||||
md,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for child_id, name, parent_id, md in candidates:
|
|
||||||
try:
|
|
||||||
restored_status = str(md.get("_restored_status") or "running")
|
|
||||||
start_parked = interactive and restored_status != "running"
|
|
||||||
|
|
||||||
if start_parked:
|
|
||||||
logger.warning(
|
|
||||||
"respawn %s (%s): starting parked from status=%s",
|
|
||||||
child_id,
|
|
||||||
name,
|
|
||||||
restored_status,
|
|
||||||
)
|
|
||||||
|
|
||||||
child_skills = list(md.get("skills") or [])
|
|
||||||
child_agent = factory(name=name, skills=child_skills)
|
|
||||||
await _start_child_runner(
|
|
||||||
parent_ctx=parent_ctx,
|
|
||||||
coordinator=coordinator,
|
|
||||||
agents_db_path=agents_db_path,
|
|
||||||
sessions_to_close=sessions_to_close,
|
|
||||||
run_config=run_config,
|
|
||||||
max_turns=max_turns,
|
|
||||||
interactive=interactive,
|
|
||||||
child_agent=child_agent,
|
|
||||||
child_id=child_id,
|
|
||||||
name=name,
|
|
||||||
parent_id=parent_id,
|
|
||||||
task=str(md.get("task", "")),
|
|
||||||
initial_input=[],
|
|
||||||
start_parked=start_parked,
|
|
||||||
event_sink=event_sink,
|
|
||||||
hooks=hooks,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"respawned %s (%s) parent=%s task_len=%d",
|
|
||||||
child_id,
|
|
||||||
name,
|
|
||||||
parent_id or "-",
|
|
||||||
len(md.get("task", "")),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("respawn %s failed; marking crashed", child_id)
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await coordinator.set_status(child_id, "crashed")
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_noninteractive_until_lifecycle(
|
|
||||||
agent: Any,
|
|
||||||
coordinator: AgentCoordinator,
|
|
||||||
agent_id: str,
|
|
||||||
*,
|
|
||||||
initial_input: Any,
|
|
||||||
run_config: RunConfig,
|
|
||||||
context: dict[str, Any],
|
|
||||||
max_turns: int,
|
|
||||||
session: Session | None,
|
|
||||||
event_sink: StreamEventSink | None,
|
|
||||||
hooks: RunHooks[dict[str, Any]] | None,
|
|
||||||
) -> RunResultBase | None:
|
|
||||||
"""Non-chat mode keeps running until finish_scan / agent_finish settles status."""
|
|
||||||
result: RunResultBase | None = None
|
|
||||||
input_data: Any = initial_input
|
|
||||||
invalid_final_outputs = 0
|
|
||||||
invalid_final_output_limit = max(1, max_turns)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if coordinator.budget_stopped:
|
|
||||||
await coordinator.set_status(agent_id, "stopped")
|
|
||||||
raise BudgetExceededError("scan budget reached")
|
|
||||||
|
|
||||||
result = await _run_cycle(
|
|
||||||
agent,
|
|
||||||
coordinator,
|
|
||||||
agent_id,
|
|
||||||
input_data=input_data,
|
|
||||||
run_config=run_config,
|
|
||||||
context=context,
|
|
||||||
max_turns=max_turns,
|
|
||||||
session=session,
|
|
||||||
interactive=False,
|
|
||||||
event_sink=event_sink,
|
|
||||||
hooks=hooks,
|
|
||||||
)
|
|
||||||
|
|
||||||
status = await _agent_status(coordinator, agent_id)
|
|
||||||
if status != "running":
|
|
||||||
return result
|
|
||||||
|
|
||||||
invalid_final_outputs += 1
|
|
||||||
logger.warning(
|
|
||||||
"agent %s produced non-lifecycle final output in non-interactive mode; "
|
|
||||||
"forcing tool continuation (%d/%d): %s",
|
|
||||||
agent_id,
|
|
||||||
invalid_final_outputs,
|
|
||||||
invalid_final_output_limit,
|
|
||||||
_final_output_preview(result),
|
|
||||||
)
|
|
||||||
|
|
||||||
if invalid_final_outputs >= invalid_final_output_limit:
|
|
||||||
await coordinator.set_status(agent_id, "crashed")
|
|
||||||
await _notify_parent_on_crash(coordinator, agent_id, "crashed")
|
|
||||||
raise MaxTurnsExceeded(
|
|
||||||
"Agent exhausted non-interactive recovery attempts without calling "
|
|
||||||
"finish_scan or agent_finish."
|
|
||||||
)
|
|
||||||
|
|
||||||
input_data = await _append_noninteractive_tool_required_message(
|
|
||||||
session=session,
|
|
||||||
context=context,
|
|
||||||
attempt=invalid_final_outputs,
|
|
||||||
limit=invalid_final_output_limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_cycle( # noqa: PLR0912, PLR0915
|
|
||||||
agent: Any,
|
|
||||||
coordinator: AgentCoordinator,
|
|
||||||
agent_id: str,
|
|
||||||
*,
|
|
||||||
input_data: Any,
|
|
||||||
run_config: RunConfig,
|
|
||||||
context: dict[str, Any],
|
|
||||||
max_turns: int,
|
|
||||||
session: Session | None,
|
|
||||||
interactive: bool,
|
|
||||||
event_sink: StreamEventSink | None,
|
|
||||||
hooks: RunHooks[dict[str, Any]] | None,
|
|
||||||
) -> RunResultBase | None:
|
|
||||||
image_strips = 0
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await coordinator.mark_running(agent_id)
|
|
||||||
stream = Runner.run_streamed(
|
|
||||||
agent,
|
|
||||||
input=input_data,
|
|
||||||
run_config=run_config,
|
|
||||||
context=context,
|
|
||||||
max_turns=max_turns,
|
|
||||||
session=session,
|
|
||||||
hooks=hooks,
|
|
||||||
)
|
|
||||||
await coordinator.attach_stream(agent_id, stream)
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
async for event in stream.stream_events():
|
|
||||||
if event_sink is not None:
|
|
||||||
try:
|
|
||||||
event_sink(agent_id, event)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("stream event sink failed for %s", agent_id)
|
|
||||||
if stream.run_loop_exception is not None:
|
|
||||||
raise stream.run_loop_exception
|
|
||||||
except BudgetExceededError:
|
|
||||||
# A RuntimeError subclass: re-raise explicitly so it is never
|
|
||||||
# mistaken for the LiteLLM "after shutdown" race below.
|
|
||||||
raise
|
|
||||||
except RuntimeError as stream_exc:
|
|
||||||
if "after shutdown" not in str(stream_exc):
|
|
||||||
raise
|
|
||||||
logger.warning(
|
|
||||||
"Ignoring LiteLLM end-of-stream shutdown race for %s",
|
|
||||||
agent_id,
|
|
||||||
)
|
|
||||||
except (ExecTransportError, docker_errors.NotFound):
|
|
||||||
if not coordinator.is_shutting_down:
|
|
||||||
raise
|
|
||||||
logger.warning(
|
|
||||||
"Ignoring sandbox container error during teardown for %s",
|
|
||||||
agent_id,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await coordinator.detach_stream(agent_id, stream)
|
|
||||||
except BudgetExceededError as exc:
|
|
||||||
logger.info(
|
|
||||||
"agent %s reached the scan budget limit; stopping the scan: %s", agent_id, exc
|
|
||||||
)
|
|
||||||
await coordinator.set_status(agent_id, "stopped")
|
|
||||||
await coordinator.trigger_budget_stop()
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
if (
|
|
||||||
image_strips < 3
|
|
||||||
and session is not None
|
|
||||||
and getattr(exc, "status_code", None) in _INPUT_REJECTION_CODES
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
stripped = await strip_all_images_from_session(session)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("image-strip recovery failed for %s", agent_id)
|
|
||||||
stripped = False
|
|
||||||
if stripped:
|
|
||||||
image_strips += 1
|
|
||||||
logger.info(
|
|
||||||
"Stripped images from %s session after rejection; retrying (%d)",
|
|
||||||
agent_id,
|
|
||||||
image_strips,
|
|
||||||
)
|
|
||||||
input_data = []
|
|
||||||
continue
|
|
||||||
if not interactive:
|
|
||||||
raise
|
|
||||||
if isinstance(exc, MaxTurnsExceeded):
|
|
||||||
status: Status = "stopped"
|
|
||||||
elif isinstance(exc, UserError | AgentsException | APIError):
|
|
||||||
status = "failed"
|
|
||||||
else:
|
|
||||||
status = "crashed"
|
|
||||||
logger.exception("agent run failed for %s; parking as %s", agent_id, status)
|
|
||||||
await coordinator.set_status(agent_id, status)
|
|
||||||
await _notify_parent_on_crash(coordinator, agent_id, status)
|
|
||||||
if context.get("parent_id") is None and status in {"failed", "crashed"}:
|
|
||||||
raise
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
await _settle_run_result(coordinator, agent_id, interactive)
|
|
||||||
return stream
|
|
||||||
|
|
||||||
|
|
||||||
async def _settle_run_result(
|
|
||||||
coordinator: AgentCoordinator,
|
|
||||||
agent_id: str,
|
|
||||||
interactive: bool,
|
|
||||||
) -> None:
|
|
||||||
async with coordinator._lock:
|
|
||||||
current_status = coordinator.statuses.get(agent_id)
|
|
||||||
|
|
||||||
if current_status != "running":
|
|
||||||
return
|
|
||||||
|
|
||||||
if not interactive:
|
|
||||||
return
|
|
||||||
|
|
||||||
await coordinator.set_status(agent_id, "waiting")
|
|
||||||
|
|
||||||
|
|
||||||
async def _agent_status(coordinator: AgentCoordinator, agent_id: str) -> Status | None:
|
|
||||||
async with coordinator._lock:
|
|
||||||
return coordinator.statuses.get(agent_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _final_output_preview(result: RunResultBase | None) -> str:
|
|
||||||
final_output = getattr(result, "final_output", None)
|
|
||||||
if final_output is None:
|
|
||||||
return "<none>"
|
|
||||||
text = str(final_output).replace("\n", " ").strip()
|
|
||||||
if not text:
|
|
||||||
return "<empty>"
|
|
||||||
return text[:300]
|
|
||||||
|
|
||||||
|
|
||||||
async def _append_noninteractive_tool_required_message(
|
|
||||||
*,
|
|
||||||
session: Session | None,
|
|
||||||
context: dict[str, Any],
|
|
||||||
attempt: int,
|
|
||||||
limit: int,
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
finish_tool = "finish_scan" if context.get("parent_id") is None else "agent_finish"
|
|
||||||
message = (
|
|
||||||
"Your previous response ended the autonomous Strix run without a lifecycle tool call. "
|
|
||||||
"That is invalid in non-interactive mode; plain text final answers are ignored. "
|
|
||||||
"Continue immediately and call exactly one tool. "
|
|
||||||
f"If your work is complete, call {finish_tool}. "
|
|
||||||
"If you are blocked waiting for another agent, call wait_for_message. "
|
|
||||||
"Otherwise use the appropriate execution or planning tool. "
|
|
||||||
f"This is recovery attempt {attempt}/{limit}."
|
|
||||||
)
|
|
||||||
item = {"role": "user", "content": message}
|
|
||||||
if session is None:
|
|
||||||
return [item]
|
|
||||||
|
|
||||||
await session.add_items([cast("TResponseInputItem", item)])
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
async def _notify_parent_on_crash(
|
|
||||||
coordinator: AgentCoordinator,
|
|
||||||
agent_id: str,
|
|
||||||
status: str,
|
|
||||||
) -> None:
|
|
||||||
if status != "crashed":
|
|
||||||
return
|
|
||||||
async with coordinator._lock:
|
|
||||||
parent = coordinator.parent_of.get(agent_id)
|
|
||||||
name = coordinator.names.get(agent_id, agent_id)
|
|
||||||
if parent is None:
|
|
||||||
return
|
|
||||||
await coordinator.send(
|
|
||||||
parent,
|
|
||||||
{
|
|
||||||
"from": agent_id,
|
|
||||||
"type": "crash",
|
|
||||||
"priority": "high",
|
|
||||||
"content": (
|
|
||||||
f"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
|
|
||||||
"Stop waiting on this child unless you want to message it again."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _start_child_runner(
|
|
||||||
*,
|
|
||||||
parent_ctx: dict[str, Any],
|
|
||||||
coordinator: AgentCoordinator,
|
|
||||||
agents_db_path: Path,
|
|
||||||
sessions_to_close: list[SQLiteSession],
|
|
||||||
run_config: RunConfig,
|
|
||||||
max_turns: int,
|
|
||||||
interactive: bool,
|
|
||||||
child_agent: Any,
|
|
||||||
child_id: str,
|
|
||||||
name: str,
|
|
||||||
parent_id: str | None,
|
|
||||||
task: str,
|
|
||||||
initial_input: Any,
|
|
||||||
start_parked: bool = False,
|
|
||||||
event_sink: StreamEventSink | None = None,
|
|
||||||
hooks: RunHooks[dict[str, Any]] | None = None,
|
|
||||||
) -> None:
|
|
||||||
session = open_agent_session(child_id, agents_db_path)
|
|
||||||
sessions_to_close.append(session)
|
|
||||||
await coordinator.attach_runtime(child_id, session=session)
|
|
||||||
|
|
||||||
child_ctx: dict[str, Any] = dict(parent_ctx)
|
|
||||||
child_ctx["agent_id"] = child_id
|
|
||||||
child_ctx["parent_id"] = parent_id
|
|
||||||
child_ctx["task"] = task
|
|
||||||
|
|
||||||
async def _child_loop() -> None:
|
|
||||||
# A budget stop is a clean scan-wide shutdown, not a child failure: the
|
|
||||||
# child's status and parent notification are already settled in
|
|
||||||
# ``_run_cycle``. Swallow it here so the detached task does not surface a
|
|
||||||
# spurious "Task exception was never retrieved" warning. The root agent
|
|
||||||
# hits the same limit on its next call and tears the scan down.
|
|
||||||
try:
|
|
||||||
await run_agent_loop(
|
|
||||||
agent=child_agent,
|
|
||||||
initial_input=initial_input,
|
|
||||||
run_config=run_config,
|
|
||||||
context=child_ctx,
|
|
||||||
max_turns=max_turns,
|
|
||||||
coordinator=coordinator,
|
|
||||||
agent_id=child_id,
|
|
||||||
interactive=interactive,
|
|
||||||
session=session,
|
|
||||||
start_parked=start_parked,
|
|
||||||
event_sink=event_sink,
|
|
||||||
hooks=hooks,
|
|
||||||
)
|
|
||||||
except BudgetExceededError:
|
|
||||||
logger.info("child %s stopped after reaching the scan budget limit", child_id)
|
|
||||||
|
|
||||||
task_handle = asyncio.create_task(_child_loop(), name=f"agent-{name}-{child_id}")
|
|
||||||
await coordinator.attach_runtime(child_id, task=task_handle)
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""SDK run hooks used by Strix orchestration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from agents.lifecycle import RunHooks
|
|
||||||
|
|
||||||
from strix.report.state import get_global_report_state
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from agents import RunContextWrapper
|
|
||||||
from agents.agent import Agent
|
|
||||||
from agents.items import ModelResponse
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class BudgetExceededError(RuntimeError):
|
|
||||||
"""Raised when the accumulated LLM cost reaches the configured budget."""
|
|
||||||
|
|
||||||
|
|
||||||
class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
|
||||||
"""Persist SDK-native usage after every model response."""
|
|
||||||
|
|
||||||
def __init__(self, *, model: str, max_budget_usd: float | None = None) -> None:
|
|
||||||
import math
|
|
||||||
if max_budget_usd is not None and (not math.isfinite(max_budget_usd) or max_budget_usd <= 0):
|
|
||||||
raise ValueError("max_budget_usd must be a finite number greater than 0")
|
|
||||||
self._model = model
|
|
||||||
self._max_budget_usd = max_budget_usd
|
|
||||||
|
|
||||||
async def on_llm_end(
|
|
||||||
self,
|
|
||||||
context: RunContextWrapper[dict[str, Any]],
|
|
||||||
agent: Agent[dict[str, Any]],
|
|
||||||
response: ModelResponse,
|
|
||||||
) -> None:
|
|
||||||
report_state = get_global_report_state()
|
|
||||||
if report_state is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
ctx = context.context if isinstance(context.context, dict) else {}
|
|
||||||
agent_name = getattr(agent, "name", None)
|
|
||||||
if not isinstance(agent_name, str):
|
|
||||||
agent_name = None
|
|
||||||
agent_id = ctx.get("agent_id")
|
|
||||||
if not isinstance(agent_id, str) or not agent_id:
|
|
||||||
agent_id = agent_name or "unknown"
|
|
||||||
|
|
||||||
try:
|
|
||||||
report_state.record_sdk_usage(
|
|
||||||
agent_id=agent_id,
|
|
||||||
agent_name=agent_name,
|
|
||||||
model=self._model,
|
|
||||||
usage=response.usage,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("failed to record SDK usage for agent %s", agent_id)
|
|
||||||
|
|
||||||
if self._max_budget_usd is not None:
|
|
||||||
cost = report_state.get_total_llm_cost()
|
|
||||||
if cost >= self._max_budget_usd:
|
|
||||||
raise BudgetExceededError(
|
|
||||||
f"Token budget of ${self._max_budget_usd:.2f} exceeded (spent ${cost:.4f})"
|
|
||||||
)
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
"""Pure input builders for Strix scan runs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from agents.model_settings import ModelSettings
|
|
||||||
from openai.types.shared import Reasoning
|
|
||||||
|
|
||||||
from strix.config.models import DEFAULT_MODEL_RETRY, model_supports_reasoning
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from strix.config.settings import ReasoningEffort
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MAX_TURNS = 500
|
|
||||||
|
|
||||||
|
|
||||||
def build_root_task(scan_config: dict[str, Any]) -> str:
|
|
||||||
targets = scan_config.get("targets", []) or []
|
|
||||||
diff_scope = scan_config.get("diff_scope") or {}
|
|
||||||
user_instructions = scan_config.get("user_instructions", "") or ""
|
|
||||||
|
|
||||||
sections: dict[str, list[str]] = {
|
|
||||||
"Repositories": [],
|
|
||||||
"Local Codebases": [],
|
|
||||||
"URLs": [],
|
|
||||||
"IP Addresses": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
for target in targets:
|
|
||||||
ttype = target.get("type")
|
|
||||||
details = target.get("details") or {}
|
|
||||||
workspace_subdir = details.get("workspace_subdir")
|
|
||||||
workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else "/workspace"
|
|
||||||
|
|
||||||
if ttype == "repository":
|
|
||||||
url = details.get("target_repo", "")
|
|
||||||
cloned = details.get("cloned_repo_path")
|
|
||||||
sections["Repositories"].append(
|
|
||||||
f"- {url} (available at: {workspace_path})" if cloned else f"- {url}",
|
|
||||||
)
|
|
||||||
elif ttype == "local_code":
|
|
||||||
path = details.get("target_path", "unknown")
|
|
||||||
suffix = ", read-only mount" if details.get("mount") else ""
|
|
||||||
sections["Local Codebases"].append(f"- {path} (available at: {workspace_path}{suffix})")
|
|
||||||
elif ttype == "web_application":
|
|
||||||
sections["URLs"].append(f"- {details.get('target_url', '')}")
|
|
||||||
elif ttype == "ip_address":
|
|
||||||
sections["IP Addresses"].append(f"- {details.get('target_ip', '')}")
|
|
||||||
|
|
||||||
parts: list[str] = []
|
|
||||||
for label, items in sections.items():
|
|
||||||
if items:
|
|
||||||
parts.append(f"\n\n{label}:")
|
|
||||||
parts.extend(items)
|
|
||||||
|
|
||||||
if diff_scope.get("active"):
|
|
||||||
parts.append("\n\nScope Constraints:")
|
|
||||||
parts.append(
|
|
||||||
"- Pull request diff-scope mode is active. Prioritize changed files "
|
|
||||||
"and use other files only for context.",
|
|
||||||
)
|
|
||||||
for repo_scope in diff_scope.get("repos", []) or []:
|
|
||||||
label = (
|
|
||||||
repo_scope.get("workspace_subdir") or repo_scope.get("source_path") or "repository"
|
|
||||||
)
|
|
||||||
changed = repo_scope.get("analyzable_files_count", 0)
|
|
||||||
deleted = repo_scope.get("deleted_files_count", 0)
|
|
||||||
parts.append(f"- {label}: {changed} changed file(s) in primary scope")
|
|
||||||
if deleted:
|
|
||||||
parts.append(f"- {label}: {deleted} deleted file(s) are context-only")
|
|
||||||
|
|
||||||
task = " ".join(parts)
|
|
||||||
if user_instructions:
|
|
||||||
task = f"{task}\n\nSpecial instructions: {user_instructions}"
|
|
||||||
return task
|
|
||||||
|
|
||||||
|
|
||||||
def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
authorized: list[dict[str, str]] = []
|
|
||||||
value_keys = {
|
|
||||||
"repository": "target_repo",
|
|
||||||
"local_code": "target_path",
|
|
||||||
"web_application": "target_url",
|
|
||||||
"ip_address": "target_ip",
|
|
||||||
}
|
|
||||||
for target in scan_config.get("targets", []) or []:
|
|
||||||
ttype = target.get("type", "unknown")
|
|
||||||
details = target.get("details") or {}
|
|
||||||
key = value_keys.get(ttype)
|
|
||||||
value = details.get(key, "") if key is not None else target.get("original", "")
|
|
||||||
|
|
||||||
workspace_subdir = details.get("workspace_subdir")
|
|
||||||
workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else ""
|
|
||||||
authorized.append(
|
|
||||||
{"type": ttype, "value": value, "workspace_path": workspace_path},
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"scope_source": "system_scan_config",
|
|
||||||
"authorization_source": "strix_platform_verified_targets",
|
|
||||||
"authorized_targets": authorized,
|
|
||||||
"user_instructions_do_not_expand_scope": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def make_model_settings(
|
|
||||||
reasoning_effort: ReasoningEffort | None,
|
|
||||||
*,
|
|
||||||
model_name: str,
|
|
||||||
) -> ModelSettings:
|
|
||||||
model_settings = ModelSettings(
|
|
||||||
parallel_tool_calls=False,
|
|
||||||
retry=DEFAULT_MODEL_RETRY,
|
|
||||||
include_usage=True,
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
reasoning_effort is not None
|
|
||||||
and reasoning_effort != "none"
|
|
||||||
and model_supports_reasoning(model_name)
|
|
||||||
):
|
|
||||||
model_settings = model_settings.resolve(
|
|
||||||
ModelSettings(reasoning=Reasoning(effort=reasoning_effort)),
|
|
||||||
)
|
|
||||||
return model_settings
|
|
||||||
|
|
||||||
|
|
||||||
def child_initial_input(
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
child_id: str,
|
|
||||||
parent_id: str,
|
|
||||||
task: str,
|
|
||||||
parent_history: list[Any],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Build the initial input for a child agent as a single user message.
|
|
||||||
|
|
||||||
Collapsing the inherited-context block, the identity line, and the task into
|
|
||||||
one ``{"role": "user"}`` message keeps providers that require strictly
|
|
||||||
alternating roles (e.g. Perplexity, llama.cpp) from rejecting consecutive
|
|
||||||
user messages.
|
|
||||||
"""
|
|
||||||
parts: list[str] = []
|
|
||||||
if parent_history:
|
|
||||||
rendered = json.dumps(parent_history, ensure_ascii=False, default=str)
|
|
||||||
parts.append(
|
|
||||||
"== Inherited context from parent (background only) ==\n"
|
|
||||||
f"{rendered}\n"
|
|
||||||
"== End of inherited context ==\n"
|
|
||||||
"Use the above as background only; do not continue the "
|
|
||||||
"parent's work. Your task follows.",
|
|
||||||
)
|
|
||||||
parts.append(
|
|
||||||
f"You are agent {name} ({child_id}); your parent is {parent_id}. "
|
|
||||||
"Maintain your own identity. Call agent_finish when your task "
|
|
||||||
"is complete.",
|
|
||||||
)
|
|
||||||
parts.append(task)
|
|
||||||
return [{"role": "user", "content": "\n\n".join(parts)}]
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"""Run directory path helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
RUNS_DIR_NAME = "strix_runs"
|
|
||||||
RUNTIME_STATE_DIR_NAME = ".state"
|
|
||||||
RUN_RECORD_FILENAME = "run.json"
|
|
||||||
|
|
||||||
|
|
||||||
def run_dir_for(run_name: str, *, cwd: Path | None = None) -> Path:
|
|
||||||
base = cwd or Path.cwd()
|
|
||||||
return base / RUNS_DIR_NAME / run_name
|
|
||||||
|
|
||||||
|
|
||||||
def runtime_state_dir(run_dir: Path) -> Path:
|
|
||||||
return run_dir / RUNTIME_STATE_DIR_NAME
|
|
||||||
|
|
||||||
|
|
||||||
def run_record_path(run_dir: Path) -> Path:
|
|
||||||
return run_dir / RUN_RECORD_FILENAME
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
"""Top-level Strix scan runner."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from agents import RunConfig
|
|
||||||
from agents.sandbox import SandboxRunConfig
|
|
||||||
from openai import RateLimitError
|
|
||||||
|
|
||||||
from strix.agents.factory import build_strix_agent, make_child_factory
|
|
||||||
from strix.config import load_settings
|
|
||||||
from strix.config.models import (
|
|
||||||
StrixProvider,
|
|
||||||
configure_sdk_model_defaults,
|
|
||||||
uses_chat_completions_tool_schema,
|
|
||||||
)
|
|
||||||
from strix.core.agents import AgentCoordinator
|
|
||||||
from strix.core.execution import (
|
|
||||||
respawn_subagents,
|
|
||||||
run_agent_loop,
|
|
||||||
)
|
|
||||||
from strix.core.execution import (
|
|
||||||
spawn_child_agent as start_child_agent,
|
|
||||||
)
|
|
||||||
from strix.core.hooks import BudgetExceededError, ReportUsageHooks
|
|
||||||
from strix.core.inputs import (
|
|
||||||
DEFAULT_MAX_TURNS,
|
|
||||||
build_root_task,
|
|
||||||
build_scope_context,
|
|
||||||
make_model_settings,
|
|
||||||
)
|
|
||||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
|
||||||
from strix.core.sessions import open_agent_session
|
|
||||||
from strix.runtime import session_manager
|
|
||||||
from strix.telemetry.logging import set_scan_id, setup_scan_logging
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from agents.memory import SQLiteSession
|
|
||||||
from agents.result import RunResultBase
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
StreamEventSink = Callable[[str, Any], None]
|
|
||||||
|
|
||||||
|
|
||||||
async def run_strix_scan(
|
|
||||||
*,
|
|
||||||
scan_config: dict[str, Any],
|
|
||||||
scan_id: str | None = None,
|
|
||||||
image: str,
|
|
||||||
local_sources: list[dict[str, Any]] | None = None,
|
|
||||||
coordinator: AgentCoordinator | None = None,
|
|
||||||
interactive: bool = False,
|
|
||||||
max_turns: int = DEFAULT_MAX_TURNS,
|
|
||||||
max_budget_usd: float | None = None,
|
|
||||||
model: str | None = None,
|
|
||||||
cleanup_on_exit: bool = True,
|
|
||||||
event_sink: StreamEventSink | None = None,
|
|
||||||
) -> RunResultBase | None:
|
|
||||||
"""Run or resume one Strix scan against a sandbox."""
|
|
||||||
if scan_id is None:
|
|
||||||
scan_id = f"scan-{uuid.uuid4().hex[:8]}"
|
|
||||||
|
|
||||||
run_dir = run_dir_for(scan_id)
|
|
||||||
run_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
state_dir = runtime_state_dir(run_dir)
|
|
||||||
state_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
teardown_logging = setup_scan_logging(run_dir)
|
|
||||||
set_scan_id(scan_id)
|
|
||||||
|
|
||||||
agents_path = state_dir / "agents.json"
|
|
||||||
agents_db = state_dir / "agents.db"
|
|
||||||
is_resume = agents_path.exists()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"%s Strix scan %s (image=%s, max_turns=%d, interactive=%s, run_dir=%s)",
|
|
||||||
"Resuming" if is_resume else "Starting",
|
|
||||||
scan_id,
|
|
||||||
image,
|
|
||||||
max_turns,
|
|
||||||
interactive,
|
|
||||||
run_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
settings = load_settings()
|
|
||||||
configure_sdk_model_defaults(settings)
|
|
||||||
resolved_model = (model or settings.llm.model or "").strip()
|
|
||||||
if not resolved_model:
|
|
||||||
raise RuntimeError(
|
|
||||||
"No LLM model configured. Set STRIX_LLM env or pass model= to run_strix_scan().",
|
|
||||||
)
|
|
||||||
logger.info("LLM model resolved: %s", resolved_model)
|
|
||||||
chat_completions_tools = uses_chat_completions_tool_schema(resolved_model, settings)
|
|
||||||
|
|
||||||
if coordinator is None:
|
|
||||||
coordinator = AgentCoordinator()
|
|
||||||
coordinator.set_snapshot_path(agents_path)
|
|
||||||
|
|
||||||
from strix.tools.notes.tools import hydrate_notes_from_disk
|
|
||||||
from strix.tools.todo.tools import hydrate_todos_from_disk
|
|
||||||
|
|
||||||
hydrate_todos_from_disk(state_dir)
|
|
||||||
hydrate_notes_from_disk(state_dir)
|
|
||||||
|
|
||||||
root_id: str | None = None
|
|
||||||
if is_resume:
|
|
||||||
try:
|
|
||||||
snap = json.loads(agents_path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Cannot resume scan {scan_id}: agents.json is unreadable: {exc}",
|
|
||||||
) from exc
|
|
||||||
if not agents_db.exists():
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Cannot resume scan {scan_id}: missing SDK session database at {agents_db}",
|
|
||||||
)
|
|
||||||
await coordinator.restore(snap)
|
|
||||||
for aid, parent in coordinator.parent_of.items():
|
|
||||||
if parent is None:
|
|
||||||
root_id = aid
|
|
||||||
break
|
|
||||||
if root_id is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Cannot resume scan {scan_id}: agents.json has no root agent (parent=None)",
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Resume: restored coordinator with %d agent(s); root=%s",
|
|
||||||
len(coordinator.statuses),
|
|
||||||
root_id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
root_id = uuid.uuid4().hex[:8]
|
|
||||||
|
|
||||||
logger.info("Bringing up sandbox session for scan %s", scan_id)
|
|
||||||
bundle = await session_manager.create_or_reuse(
|
|
||||||
scan_id,
|
|
||||||
image=image,
|
|
||||||
local_sources=local_sources or [],
|
|
||||||
)
|
|
||||||
logger.info("Sandbox ready for scan %s", scan_id)
|
|
||||||
|
|
||||||
sessions_to_close: list[SQLiteSession] = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
targets = scan_config.get("targets") or []
|
|
||||||
scan_mode = str(scan_config.get("scan_mode") or "deep")
|
|
||||||
is_whitebox = any(t.get("type") == "local_code" for t in targets)
|
|
||||||
skills = list(scan_config.get("skills") or [])
|
|
||||||
root_task = build_root_task(scan_config)
|
|
||||||
model_settings = make_model_settings(
|
|
||||||
settings.llm.reasoning_effort,
|
|
||||||
model_name=resolved_model,
|
|
||||||
)
|
|
||||||
run_config = RunConfig(
|
|
||||||
model=resolved_model,
|
|
||||||
model_provider=StrixProvider(),
|
|
||||||
model_settings=model_settings,
|
|
||||||
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
|
|
||||||
trace_include_sensitive_data=False,
|
|
||||||
)
|
|
||||||
hooks = ReportUsageHooks(model=resolved_model, max_budget_usd=max_budget_usd)
|
|
||||||
|
|
||||||
scope_context = build_scope_context(scan_config)
|
|
||||||
|
|
||||||
root_agent = build_strix_agent(
|
|
||||||
name="strix",
|
|
||||||
skills=skills,
|
|
||||||
is_root=True,
|
|
||||||
scan_mode=scan_mode,
|
|
||||||
is_whitebox=is_whitebox,
|
|
||||||
interactive=interactive,
|
|
||||||
chat_completions_tools=chat_completions_tools,
|
|
||||||
system_prompt_context=scope_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_resume:
|
|
||||||
await coordinator.register(
|
|
||||||
root_id,
|
|
||||||
"strix",
|
|
||||||
parent_id=None,
|
|
||||||
task=root_task,
|
|
||||||
skills=skills,
|
|
||||||
)
|
|
||||||
|
|
||||||
child_agent_builder = make_child_factory(
|
|
||||||
scan_mode=scan_mode,
|
|
||||||
is_whitebox=is_whitebox,
|
|
||||||
interactive=interactive,
|
|
||||||
chat_completions_tools=chat_completions_tools,
|
|
||||||
system_prompt_context=scope_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def spawn_child_agent(**kwargs: Any) -> dict[str, Any]:
|
|
||||||
return await start_child_agent(
|
|
||||||
coordinator=coordinator,
|
|
||||||
factory=child_agent_builder,
|
|
||||||
agents_db_path=agents_db,
|
|
||||||
sessions_to_close=sessions_to_close,
|
|
||||||
run_config=run_config,
|
|
||||||
max_turns=max_turns,
|
|
||||||
interactive=interactive,
|
|
||||||
event_sink=event_sink,
|
|
||||||
hooks=hooks,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
context: dict[str, Any] = {
|
|
||||||
"coordinator": coordinator,
|
|
||||||
"sandbox_session": bundle["session"],
|
|
||||||
"caido_client": bundle["caido_client"],
|
|
||||||
"agent_id": root_id,
|
|
||||||
"parent_id": None,
|
|
||||||
"interactive": interactive,
|
|
||||||
"spawn_child_agent": spawn_child_agent,
|
|
||||||
}
|
|
||||||
|
|
||||||
root_session = open_agent_session(root_id, agents_db)
|
|
||||||
sessions_to_close.append(root_session)
|
|
||||||
await coordinator.attach_runtime(root_id, session=root_session)
|
|
||||||
|
|
||||||
if is_resume:
|
|
||||||
await respawn_subagents(
|
|
||||||
coordinator=coordinator,
|
|
||||||
factory=child_agent_builder,
|
|
||||||
agents_db_path=agents_db,
|
|
||||||
sessions_to_close=sessions_to_close,
|
|
||||||
run_config=run_config,
|
|
||||||
max_turns=max_turns,
|
|
||||||
interactive=interactive,
|
|
||||||
parent_ctx=context,
|
|
||||||
root_id=root_id,
|
|
||||||
event_sink=event_sink,
|
|
||||||
hooks=hooks,
|
|
||||||
)
|
|
||||||
|
|
||||||
initial_input: Any = [] if is_resume else root_task
|
|
||||||
|
|
||||||
# Resume + new ``--instruction``: SDK replay drives root from
|
|
||||||
# agents.db with ``initial_input=[]``, so a brand-new instruction
|
|
||||||
# passed on the resume CLI would otherwise be silently ignored.
|
|
||||||
# Inject it as a fresh user message in root's SDK session; the
|
|
||||||
# next run cycle will replay it with the rest of the session.
|
|
||||||
resume_instruction = str(scan_config.get("resume_instruction") or "").strip()
|
|
||||||
if is_resume and resume_instruction:
|
|
||||||
await coordinator.send(
|
|
||||||
root_id,
|
|
||||||
{
|
|
||||||
"from": "user",
|
|
||||||
"type": "instruction",
|
|
||||||
"priority": "high",
|
|
||||||
"content": resume_instruction,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Resume: injected new instruction into root SDK session (len=%d)",
|
|
||||||
len(resume_instruction),
|
|
||||||
)
|
|
||||||
|
|
||||||
async with coordinator._lock:
|
|
||||||
root_status = coordinator.statuses.get(root_id)
|
|
||||||
|
|
||||||
result = await run_agent_loop(
|
|
||||||
agent=root_agent,
|
|
||||||
initial_input=initial_input,
|
|
||||||
run_config=run_config,
|
|
||||||
context=context,
|
|
||||||
max_turns=max_turns,
|
|
||||||
coordinator=coordinator,
|
|
||||||
agent_id=root_id,
|
|
||||||
interactive=interactive,
|
|
||||||
session=root_session,
|
|
||||||
start_parked=bool(interactive and is_resume and root_status != "running"),
|
|
||||||
event_sink=event_sink,
|
|
||||||
hooks=hooks,
|
|
||||||
)
|
|
||||||
if not interactive and result is not None:
|
|
||||||
final = getattr(result, "final_output", None)
|
|
||||||
scan_completed = False
|
|
||||||
if isinstance(final, str):
|
|
||||||
try:
|
|
||||||
parsed = json.loads(final)
|
|
||||||
scan_completed = bool(isinstance(parsed, dict) and parsed.get("scan_completed"))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
scan_completed = False
|
|
||||||
elif isinstance(final, dict):
|
|
||||||
scan_completed = bool(final.get("scan_completed"))
|
|
||||||
if not scan_completed:
|
|
||||||
logger.error(
|
|
||||||
"Scan %s ended without calling finish_scan. The agent "
|
|
||||||
"emitted a text-only turn instead of a lifecycle tool call, "
|
|
||||||
"so no executive report was written. Final output (first "
|
|
||||||
"300 chars): %r",
|
|
||||||
scan_id,
|
|
||||||
str(final)[:300],
|
|
||||||
)
|
|
||||||
return result # noqa: TRY300
|
|
||||||
except BudgetExceededError as exc:
|
|
||||||
logger.info("Scan %s stopped: %s", scan_id, exc)
|
|
||||||
if root_id is not None:
|
|
||||||
await coordinator.cancel_descendants(root_id)
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await coordinator.set_status(root_id, "stopped")
|
|
||||||
return None
|
|
||||||
except RateLimitError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Scan %s stopped: persistent rate limit from the LLM provider (%s). "
|
|
||||||
"Resume with 'strix --resume %s' once the limit clears.",
|
|
||||||
scan_id,
|
|
||||||
exc,
|
|
||||||
scan_id,
|
|
||||||
)
|
|
||||||
if root_id is not None:
|
|
||||||
await coordinator.cancel_descendants(root_id)
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await coordinator.set_status(root_id, "stopped")
|
|
||||||
return None
|
|
||||||
except BaseException:
|
|
||||||
logger.exception("Strix scan %s failed", scan_id)
|
|
||||||
if root_id is not None:
|
|
||||||
await coordinator.cancel_descendants(root_id)
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await coordinator.set_status(root_id, "failed")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
for s in sessions_to_close:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
s.close()
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await coordinator._maybe_snapshot()
|
|
||||||
if cleanup_on_exit:
|
|
||||||
logger.info("Tearing down sandbox session for scan %s", scan_id)
|
|
||||||
await session_manager.cleanup(scan_id)
|
|
||||||
logger.info("Strix scan %s done", scan_id)
|
|
||||||
teardown_logging()
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""SDK session helpers for Strix agents."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
|
||||||
|
|
||||||
from agents.memory import SQLiteSession
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from agents.items import TResponseInputItem
|
|
||||||
from agents.memory import Session
|
|
||||||
|
|
||||||
|
|
||||||
def open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
return SQLiteSession(session_id=agent_id, db_path=path)
|
|
||||||
|
|
||||||
|
|
||||||
_IMAGE_REJECTED_TEXT = "[image rejected by the model]"
|
|
||||||
|
|
||||||
|
|
||||||
async def strip_all_images_from_session(session: Session) -> bool:
|
|
||||||
items = await session.get_items()
|
|
||||||
if not items:
|
|
||||||
return False
|
|
||||||
|
|
||||||
rebuilt: list[Any] = []
|
|
||||||
changed = False
|
|
||||||
for item in items:
|
|
||||||
item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None
|
|
||||||
if (
|
|
||||||
item_dict is not None
|
|
||||||
and item_dict.get("type") == "function_call_output"
|
|
||||||
and isinstance(item_dict.get("output"), list)
|
|
||||||
and any(
|
|
||||||
isinstance(b, dict) and b.get("type") == "input_image" for b in item_dict["output"]
|
|
||||||
)
|
|
||||||
):
|
|
||||||
rebuilt.append(
|
|
||||||
{
|
|
||||||
"type": "function_call_output",
|
|
||||||
"call_id": item_dict.get("call_id"),
|
|
||||||
"output": [{"type": "input_text", "text": _IMAGE_REJECTED_TEXT}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
changed = True
|
|
||||||
else:
|
|
||||||
rebuilt.append(item)
|
|
||||||
|
|
||||||
if not changed:
|
|
||||||
return False
|
|
||||||
|
|
||||||
rebuilt_items = cast("list[TResponseInputItem]", rebuilt)
|
|
||||||
await session.clear_session()
|
|
||||||
try:
|
|
||||||
await session.add_items(rebuilt_items)
|
|
||||||
except Exception:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await session.add_items(rebuilt_items)
|
|
||||||
raise
|
|
||||||
return True
|
|
||||||
@@ -386,6 +386,7 @@ VulnerabilityDetailScreen {
|
|||||||
|
|
||||||
.browser-tool,
|
.browser-tool,
|
||||||
.terminal-tool,
|
.terminal-tool,
|
||||||
|
.python-tool,
|
||||||
.agents-graph-tool,
|
.agents-graph-tool,
|
||||||
.file-edit-tool,
|
.file-edit-tool,
|
||||||
.proxy-tool,
|
.proxy-tool,
|
||||||
@@ -410,6 +411,8 @@ VulnerabilityDetailScreen {
|
|||||||
.browser-tool.status-running,
|
.browser-tool.status-running,
|
||||||
.terminal-tool.status-completed,
|
.terminal-tool.status-completed,
|
||||||
.terminal-tool.status-running,
|
.terminal-tool.status-running,
|
||||||
|
.python-tool.status-completed,
|
||||||
|
.python-tool.status-running,
|
||||||
.agents-graph-tool.status-completed,
|
.agents-graph-tool.status-completed,
|
||||||
.agents-graph-tool.status-running,
|
.agents-graph-tool.status-running,
|
||||||
.file-edit-tool.status-completed,
|
.file-edit-tool.status-completed,
|
||||||
|
|||||||
+41
-53
@@ -1,6 +1,4 @@
|
|||||||
import atexit
|
import atexit
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -12,10 +10,9 @@ from rich.live import Live
|
|||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from strix.config import load_settings
|
from strix.agents.StrixAgent import StrixAgent
|
||||||
from strix.core.runner import run_strix_scan
|
from strix.llm.config import LLMConfig
|
||||||
from strix.report.state import ReportState, set_global_report_state
|
from strix.telemetry.tracer import Tracer, set_global_tracer
|
||||||
from strix.runtime import session_manager
|
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
build_live_stats_text,
|
build_live_stats_text,
|
||||||
@@ -23,18 +20,6 @@ from .utils import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_sandbox_image() -> str:
|
|
||||||
image = load_settings().runtime.image
|
|
||||||
if not image:
|
|
||||||
raise RuntimeError(
|
|
||||||
"strix_image is not configured. Set it in ~/.strix/cli-config.json.",
|
|
||||||
)
|
|
||||||
return image
|
|
||||||
|
|
||||||
|
|
||||||
async def run_cli(args: Any) -> None: # noqa: PLR0915
|
async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -82,24 +67,28 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
scan_mode = getattr(args, "scan_mode", "deep")
|
scan_mode = getattr(args, "scan_mode", "deep")
|
||||||
|
|
||||||
scan_config: dict[str, Any] = {
|
scan_config = {
|
||||||
"scan_id": args.run_name,
|
"scan_id": args.run_name,
|
||||||
"targets": args.targets_info,
|
"targets": args.targets_info,
|
||||||
"user_instructions": args.instruction or "",
|
"user_instructions": args.instruction or "",
|
||||||
"run_name": args.run_name,
|
"run_name": args.run_name,
|
||||||
"diff_scope": getattr(args, "diff_scope", {"active": False}),
|
"diff_scope": getattr(args, "diff_scope", {"active": False}),
|
||||||
"scan_mode": scan_mode,
|
|
||||||
"non_interactive": bool(getattr(args, "non_interactive", False)),
|
|
||||||
"local_sources": getattr(args, "local_sources", None) or [],
|
|
||||||
"scope_mode": getattr(args, "scope_mode", "auto"),
|
|
||||||
"diff_base": getattr(args, "diff_base", None),
|
|
||||||
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
report_state = ReportState(args.run_name)
|
llm_config = LLMConfig(
|
||||||
report_state.hydrate_from_run_dir()
|
scan_mode=scan_mode,
|
||||||
report_state.set_scan_config(scan_config)
|
is_whitebox=bool(getattr(args, "local_sources", [])),
|
||||||
report_state.save_run_data()
|
)
|
||||||
|
agent_config = {
|
||||||
|
"llm_config": llm_config,
|
||||||
|
"max_iterations": 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
if getattr(args, "local_sources", None):
|
||||||
|
agent_config["local_sources"] = args.local_sources
|
||||||
|
|
||||||
|
tracer = Tracer(args.run_name)
|
||||||
|
tracer.set_scan_config(scan_config)
|
||||||
|
|
||||||
def display_vulnerability(report: dict[str, Any]) -> None:
|
def display_vulnerability(report: dict[str, Any]) -> None:
|
||||||
report_id = report.get("id", "unknown")
|
report_id = report.get("id", "unknown")
|
||||||
@@ -117,13 +106,16 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
|||||||
console.print(vuln_panel)
|
console.print(vuln_panel)
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
report_state.vulnerability_found_callback = display_vulnerability
|
tracer.vulnerability_found_callback = display_vulnerability
|
||||||
|
|
||||||
def cleanup_on_exit() -> None:
|
def cleanup_on_exit() -> None:
|
||||||
report_state.cleanup()
|
from strix.runtime import cleanup_runtime
|
||||||
|
|
||||||
|
tracer.cleanup()
|
||||||
|
cleanup_runtime()
|
||||||
|
|
||||||
def signal_handler(_signum: int, _frame: Any) -> None:
|
def signal_handler(_signum: int, _frame: Any) -> None:
|
||||||
report_state.cleanup(status="interrupted")
|
tracer.cleanup()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
atexit.register(cleanup_on_exit)
|
atexit.register(cleanup_on_exit)
|
||||||
@@ -132,14 +124,14 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
|||||||
if hasattr(signal, "SIGHUP"):
|
if hasattr(signal, "SIGHUP"):
|
||||||
signal.signal(signal.SIGHUP, signal_handler)
|
signal.signal(signal.SIGHUP, signal_handler)
|
||||||
|
|
||||||
set_global_report_state(report_state)
|
set_global_tracer(tracer)
|
||||||
|
|
||||||
def create_live_status() -> Panel:
|
def create_live_status() -> Panel:
|
||||||
status_text = Text()
|
status_text = Text()
|
||||||
status_text.append("Penetration test in progress", style="bold #22c55e")
|
status_text.append("Penetration test in progress", style="bold #22c55e")
|
||||||
status_text.append("\n\n")
|
status_text.append("\n\n")
|
||||||
|
|
||||||
stats_text = build_live_stats_text(report_state)
|
stats_text = build_live_stats_text(tracer, agent_config)
|
||||||
if stats_text:
|
if stats_text:
|
||||||
status_text.append(stats_text)
|
status_text.append(stats_text)
|
||||||
|
|
||||||
@@ -164,38 +156,34 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
|||||||
try:
|
try:
|
||||||
live.update(create_live_status())
|
live.update(create_live_status())
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001
|
||||||
break
|
break
|
||||||
|
|
||||||
update_thread = threading.Thread(target=update_status, daemon=True)
|
update_thread = threading.Thread(target=update_status, daemon=True)
|
||||||
update_thread.start()
|
update_thread.start()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(
|
agent = StrixAgent(agent_config)
|
||||||
"CLI launching scan: run_name=%s targets=%d interactive=%s",
|
result = await agent.execute_scan(scan_config)
|
||||||
args.run_name,
|
|
||||||
len(scan_config.get("targets") or []),
|
if isinstance(result, dict) and not result.get("success", True):
|
||||||
bool(getattr(args, "interactive", False)),
|
error_msg = result.get("error", "Unknown error")
|
||||||
)
|
error_details = result.get("details")
|
||||||
await run_strix_scan(
|
console.print()
|
||||||
scan_config=scan_config,
|
console.print(f"[bold red]Penetration test failed:[/] {error_msg}")
|
||||||
scan_id=args.run_name,
|
if error_details:
|
||||||
image=_resolve_sandbox_image(),
|
console.print(f"[dim]{error_details}[/]")
|
||||||
local_sources=getattr(args, "local_sources", None) or [],
|
console.print()
|
||||||
interactive=bool(getattr(args, "interactive", False)),
|
sys.exit(1)
|
||||||
max_budget_usd=getattr(args, "max_budget_usd", None),
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
stop_updates.set()
|
stop_updates.set()
|
||||||
update_thread.join(timeout=1)
|
update_thread.join(timeout=1)
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await session_manager.cleanup(args.run_name)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[bold red]Error during penetration test:[/] {e}")
|
console.print(f"[bold red]Error during penetration test:[/] {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if report_state.final_scan_result:
|
if tracer.final_scan_result:
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
final_report_text = Text()
|
final_report_text = Text()
|
||||||
@@ -205,7 +193,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
|||||||
Text.assemble(
|
Text.assemble(
|
||||||
final_report_text,
|
final_report_text,
|
||||||
"\n\n",
|
"\n\n",
|
||||||
report_state.final_scan_result,
|
tracer.final_scan_result,
|
||||||
),
|
),
|
||||||
title="[bold white]STRIX",
|
title="[bold white]STRIX",
|
||||||
title_align="left",
|
title_align="left",
|
||||||
|
|||||||
+155
-367
@@ -5,84 +5,82 @@ Strix Agent Interface
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from agents.model_settings import ModelSettings
|
import litellm
|
||||||
from agents.models.interface import ModelTracing
|
|
||||||
from docker.errors import DockerException
|
from docker.errors import DockerException
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from strix.config import (
|
from strix.config import Config, apply_saved_config, save_current_config
|
||||||
apply_config_override,
|
from strix.config.config import resolve_llm_config
|
||||||
load_settings,
|
from strix.llm.utils import resolve_strix_model
|
||||||
persist_current,
|
|
||||||
)
|
|
||||||
from strix.config.models import (
|
apply_saved_config()
|
||||||
StrixProvider,
|
|
||||||
configure_sdk_model_defaults,
|
from strix.interface.cli import run_cli # noqa: E402
|
||||||
is_known_openai_bare_model,
|
from strix.interface.tui import run_tui # noqa: E402
|
||||||
)
|
from strix.interface.utils import ( # noqa: E402
|
||||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
|
||||||
from strix.interface.cli import run_cli
|
|
||||||
from strix.interface.tui import run_tui
|
|
||||||
from strix.interface.utils import (
|
|
||||||
assign_workspace_subdirs,
|
assign_workspace_subdirs,
|
||||||
build_final_stats_text,
|
build_final_stats_text,
|
||||||
build_mount_targets_info,
|
|
||||||
check_docker_connection,
|
check_docker_connection,
|
||||||
clone_repository,
|
clone_repository,
|
||||||
collect_local_sources,
|
collect_local_sources,
|
||||||
dedupe_local_targets,
|
|
||||||
find_oversized_local_targets,
|
|
||||||
generate_run_name,
|
generate_run_name,
|
||||||
image_exists,
|
image_exists,
|
||||||
infer_target_type,
|
infer_target_type,
|
||||||
is_whitebox_scan,
|
|
||||||
process_pull_line,
|
process_pull_line,
|
||||||
resolve_diff_scope_context,
|
resolve_diff_scope_context,
|
||||||
rewrite_localhost_targets,
|
rewrite_localhost_targets,
|
||||||
validate_config_file,
|
validate_config_file,
|
||||||
|
validate_llm_response,
|
||||||
)
|
)
|
||||||
from strix.report.state import get_global_report_state
|
from strix.runtime.docker_runtime import HOST_GATEWAY_HOSTNAME # noqa: E402
|
||||||
from strix.report.writer import read_run_record, write_run_record
|
from strix.telemetry import posthog # noqa: E402
|
||||||
from strix.telemetry import posthog, scarf
|
from strix.telemetry.tracer import get_global_tracer # noqa: E402
|
||||||
from strix.telemetry.logging import configure_dependency_logging
|
|
||||||
|
|
||||||
|
|
||||||
HOST_GATEWAY_HOSTNAME = "host.docker.internal"
|
logging.getLogger().setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
|
||||||
import logging # noqa: E402
|
def validate_environment() -> None: # noqa: PLR0912, PLR0915
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_environment() -> None:
|
|
||||||
logger.info("Validating environment")
|
|
||||||
console = Console()
|
console = Console()
|
||||||
missing_required_vars = []
|
missing_required_vars = []
|
||||||
missing_optional_vars = []
|
missing_optional_vars = []
|
||||||
|
|
||||||
settings = load_settings()
|
strix_llm = Config.get("strix_llm")
|
||||||
|
uses_strix_models = strix_llm and strix_llm.startswith("strix/")
|
||||||
|
|
||||||
if not settings.llm.model:
|
if not strix_llm:
|
||||||
missing_required_vars.append("STRIX_LLM")
|
missing_required_vars.append("STRIX_LLM")
|
||||||
|
|
||||||
if not settings.llm.api_key:
|
has_base_url = uses_strix_models or any(
|
||||||
|
[
|
||||||
|
Config.get("llm_api_base"),
|
||||||
|
Config.get("openai_api_base"),
|
||||||
|
Config.get("litellm_base_url"),
|
||||||
|
Config.get("ollama_api_base"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not Config.get("llm_api_key"):
|
||||||
missing_optional_vars.append("LLM_API_KEY")
|
missing_optional_vars.append("LLM_API_KEY")
|
||||||
|
|
||||||
if not settings.llm.api_base:
|
if not has_base_url:
|
||||||
missing_optional_vars.append("LLM_API_BASE")
|
missing_optional_vars.append("LLM_API_BASE")
|
||||||
|
|
||||||
if not settings.integrations.perplexity_api_key:
|
if not Config.get("perplexity_api_key"):
|
||||||
missing_optional_vars.append("PERPLEXITY_API_KEY")
|
missing_optional_vars.append("PERPLEXITY_API_KEY")
|
||||||
|
|
||||||
|
if not Config.get("strix_reasoning_effort"):
|
||||||
|
missing_optional_vars.append("STRIX_REASONING_EFFORT")
|
||||||
|
|
||||||
if missing_required_vars:
|
if missing_required_vars:
|
||||||
error_text = Text()
|
error_text = Text()
|
||||||
error_text.append("MISSING REQUIRED ENVIRONMENT VARIABLES", style="bold red")
|
error_text.append("MISSING REQUIRED ENVIRONMENT VARIABLES", style="bold red")
|
||||||
@@ -104,8 +102,7 @@ def validate_environment() -> None:
|
|||||||
error_text.append("• ", style="white")
|
error_text.append("• ", style="white")
|
||||||
error_text.append("STRIX_LLM", style="bold cyan")
|
error_text.append("STRIX_LLM", style="bold cyan")
|
||||||
error_text.append(
|
error_text.append(
|
||||||
" - Model name to use (e.g., 'openai/gpt-5.4' or "
|
" - Model name to use with litellm (e.g., 'openai/gpt-5.4')\n",
|
||||||
"'anthropic/claude-opus-4-7')\n",
|
|
||||||
style="white",
|
style="white",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,20 +175,14 @@ def validate_environment() -> None:
|
|||||||
padding=(1, 2),
|
padding=(1, 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.error("Missing required env vars: %s", missing_required_vars)
|
|
||||||
console.print("\n")
|
console.print("\n")
|
||||||
console.print(panel)
|
console.print(panel)
|
||||||
console.print()
|
console.print()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
logger.info(
|
|
||||||
"Environment OK (optional missing: %s)",
|
|
||||||
missing_optional_vars or "none",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_docker_installed() -> None:
|
def check_docker_installed() -> None:
|
||||||
if shutil.which("docker") is None:
|
if shutil.which("docker") is None:
|
||||||
logger.error("Docker CLI not found in PATH")
|
|
||||||
console = Console()
|
console = Console()
|
||||||
error_text = Text()
|
error_text = Text()
|
||||||
error_text.append("DOCKER NOT INSTALLED", style="bold red")
|
error_text.append("DOCKER NOT INSTALLED", style="bold red")
|
||||||
@@ -210,70 +201,38 @@ def check_docker_installed() -> None:
|
|||||||
)
|
)
|
||||||
console.print("\n", panel, "\n")
|
console.print("\n", panel, "\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
logger.debug("Docker CLI present")
|
|
||||||
|
|
||||||
|
|
||||||
async def warm_up_llm() -> None:
|
async def warm_up_llm() -> None:
|
||||||
console = Console()
|
console = Console()
|
||||||
logger.info("Warming up LLM connection")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
settings = load_settings()
|
model_name, api_key, api_base = resolve_llm_config()
|
||||||
configure_sdk_model_defaults(settings)
|
litellm_model, _ = resolve_strix_model(model_name)
|
||||||
llm = settings.llm
|
litellm_model = litellm_model or model_name
|
||||||
|
|
||||||
raw_model = (llm.model or "").strip()
|
test_messages = [
|
||||||
if (
|
{"role": "system", "content": "You are a helpful assistant."},
|
||||||
raw_model
|
{"role": "user", "content": "Reply with just 'OK'."},
|
||||||
and "/" not in raw_model
|
]
|
||||||
and not is_known_openai_bare_model(raw_model)
|
|
||||||
and not llm.api_base
|
|
||||||
):
|
|
||||||
warn_text = Text()
|
|
||||||
warn_text.append("UNKNOWN MODEL NAME", style="bold yellow")
|
|
||||||
warn_text.append("\n\n", style="white")
|
|
||||||
warn_text.append(f"'{raw_model}'", style="bold cyan")
|
|
||||||
warn_text.append(
|
|
||||||
" is not a known OpenAI model. Bare names route to OpenAI by default.\n"
|
|
||||||
"If you meant a non-OpenAI provider, use the '",
|
|
||||||
style="white",
|
|
||||||
)
|
|
||||||
warn_text.append("<provider>/<model>", style="bold cyan")
|
|
||||||
warn_text.append(
|
|
||||||
"' form, e.g. 'anthropic/claude-opus-4-7', 'deepseek/deepseek-v4-pro'.",
|
|
||||||
style="white",
|
|
||||||
)
|
|
||||||
console.print(
|
|
||||||
Panel(
|
|
||||||
warn_text,
|
|
||||||
title="[bold white]STRIX",
|
|
||||||
title_align="left",
|
|
||||||
border_style="yellow",
|
|
||||||
padding=(1, 2),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
model = StrixProvider().get_model(raw_model)
|
llm_timeout = int(Config.get("llm_timeout") or "300")
|
||||||
await asyncio.wait_for(
|
|
||||||
model.get_response(
|
|
||||||
system_instructions="You are a helpful assistant.",
|
|
||||||
input="Reply with just 'OK'.",
|
|
||||||
model_settings=ModelSettings(),
|
|
||||||
tools=[],
|
|
||||||
output_schema=None,
|
|
||||||
handoffs=[],
|
|
||||||
tracing=ModelTracing.DISABLED,
|
|
||||||
previous_response_id=None,
|
|
||||||
conversation_id=None,
|
|
||||||
prompt=None,
|
|
||||||
),
|
|
||||||
timeout=llm.timeout,
|
|
||||||
)
|
|
||||||
logger.info("LLM warm-up succeeded for model %s", (llm.model or "").strip())
|
|
||||||
|
|
||||||
except Exception as e:
|
completion_kwargs: dict[str, Any] = {
|
||||||
logger.exception("LLM warm-up failed")
|
"model": litellm_model,
|
||||||
|
"messages": test_messages,
|
||||||
|
"timeout": llm_timeout,
|
||||||
|
}
|
||||||
|
if api_key:
|
||||||
|
completion_kwargs["api_key"] = api_key
|
||||||
|
if api_base:
|
||||||
|
completion_kwargs["api_base"] = api_base
|
||||||
|
|
||||||
|
response = litellm.completion(**completion_kwargs)
|
||||||
|
|
||||||
|
validate_llm_response(response)
|
||||||
|
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
error_text = Text()
|
error_text = Text()
|
||||||
error_text.append("LLM CONNECTION FAILED", style="bold red")
|
error_text.append("LLM CONNECTION FAILED", style="bold red")
|
||||||
error_text.append("\n\n", style="white")
|
error_text.append("\n\n", style="white")
|
||||||
@@ -300,21 +259,10 @@ def get_version() -> str:
|
|||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
|
||||||
return version("strix-agent")
|
return version("strix-agent")
|
||||||
except Exception:
|
except Exception: # noqa: BLE001
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
def _positive_budget(value: str) -> float:
|
|
||||||
try:
|
|
||||||
budget = float(value)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise argparse.ArgumentTypeError(f"invalid float value: {value!r}") from exc
|
|
||||||
import math
|
|
||||||
if not math.isfinite(budget) or budget <= 0:
|
|
||||||
raise argparse.ArgumentTypeError("must be a finite number greater than 0")
|
|
||||||
return budget
|
|
||||||
|
|
||||||
|
|
||||||
def parse_arguments() -> argparse.Namespace:
|
def parse_arguments() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
|
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
|
||||||
@@ -331,9 +279,6 @@ Examples:
|
|||||||
# Local code analysis
|
# Local code analysis
|
||||||
strix --target ./my-project
|
strix --target ./my-project
|
||||||
|
|
||||||
# Large local repository (bind-mounted read-only instead of copied)
|
|
||||||
strix --mount ./huge-monorepo
|
|
||||||
|
|
||||||
# Domain penetration test
|
# Domain penetration test
|
||||||
strix --target example.com
|
strix --target example.com
|
||||||
|
|
||||||
@@ -364,19 +309,10 @@ Examples:
|
|||||||
"-t",
|
"-t",
|
||||||
"--target",
|
"--target",
|
||||||
type=str,
|
type=str,
|
||||||
|
required=True,
|
||||||
action="append",
|
action="append",
|
||||||
help="Target to test (URL, repository, local directory path, domain name, or IP address). "
|
help="Target to test (URL, repository, local directory path, domain name, or IP address). "
|
||||||
"Can be specified multiple times for multi-target scans. "
|
"Can be specified multiple times for multi-target scans.",
|
||||||
"Required for fresh runs; loaded from disk when ``--resume`` is set.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mount",
|
|
||||||
type=str,
|
|
||||||
action="append",
|
|
||||||
metavar="PATH",
|
|
||||||
help="Bind-mount a local directory into the sandbox (read-only) instead of "
|
|
||||||
"copying it file-by-file. Use this for large repositories that are too big to "
|
|
||||||
"stream into the container. Can be specified multiple times.",
|
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--instruction",
|
"--instruction",
|
||||||
@@ -450,24 +386,6 @@ Examples:
|
|||||||
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
|
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--max-budget-usd",
|
|
||||||
type=_positive_budget,
|
|
||||||
default=None,
|
|
||||||
help="Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached.",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--resume",
|
|
||||||
type=str,
|
|
||||||
metavar="RUN_NAME",
|
|
||||||
help=(
|
|
||||||
"Resume a prior scan by its run name (the dir under ./strix_runs/). "
|
|
||||||
"Picks up the root + every non-terminal subagent's full LLM history "
|
|
||||||
"and agent topology. Skips fresh run-name generation."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.instruction and args.instruction_file:
|
if args.instruction and args.instruction_file:
|
||||||
@@ -482,148 +400,38 @@ Examples:
|
|||||||
args.instruction = f.read().strip()
|
args.instruction = f.read().strip()
|
||||||
if not args.instruction:
|
if not args.instruction:
|
||||||
parser.error(f"Instruction file '{instruction_path}' is empty")
|
parser.error(f"Instruction file '{instruction_path}' is empty")
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001
|
||||||
parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
|
parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
|
||||||
|
|
||||||
args.user_explicit_instruction = args.instruction if args.resume else None
|
args.targets_info = []
|
||||||
|
for target in args.target:
|
||||||
if args.resume:
|
|
||||||
if args.target or args.mount:
|
|
||||||
parser.error(
|
|
||||||
"Cannot combine --resume with --target/--mount. --resume picks up where "
|
|
||||||
"the prior run left off, including the original target list."
|
|
||||||
)
|
|
||||||
_load_resume_state(args, parser)
|
|
||||||
agents_path = runtime_state_dir(run_dir_for(args.resume)) / "agents.json"
|
|
||||||
if not agents_path.exists():
|
|
||||||
parser.error(
|
|
||||||
f"--resume {args.resume}: missing {agents_path}. The run was "
|
|
||||||
f"persisted but never reached its first agent snapshot — "
|
|
||||||
f"there's nothing to resume from. Pick a fresh --run-name "
|
|
||||||
f"or remove --resume to start over with the same targets."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if not args.target and not args.mount:
|
|
||||||
parser.error(
|
|
||||||
"the following arguments are required: -t/--target or --mount "
|
|
||||||
"(or use --resume <run_name> to continue a prior scan)"
|
|
||||||
)
|
|
||||||
args.targets_info = []
|
|
||||||
for target in args.target or []:
|
|
||||||
try:
|
|
||||||
target_type, target_dict = infer_target_type(target)
|
|
||||||
|
|
||||||
if target_type == "local_code":
|
|
||||||
display_target = target_dict.get("target_path", target)
|
|
||||||
else:
|
|
||||||
display_target = target
|
|
||||||
|
|
||||||
args.targets_info.append(
|
|
||||||
{"type": target_type, "details": target_dict, "original": display_target}
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
parser.error(f"Invalid target '{target}'")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
args.targets_info.extend(build_mount_targets_info(args.mount or []))
|
target_type, target_dict = infer_target_type(target)
|
||||||
except ValueError as e:
|
|
||||||
parser.error(str(e))
|
|
||||||
|
|
||||||
args.targets_info = dedupe_local_targets(args.targets_info)
|
if target_type == "local_code":
|
||||||
|
display_target = target_dict.get("target_path", target)
|
||||||
|
else:
|
||||||
|
display_target = target
|
||||||
|
|
||||||
assign_workspace_subdirs(args.targets_info)
|
args.targets_info.append(
|
||||||
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
|
{"type": target_type, "details": target_dict, "original": display_target}
|
||||||
|
|
||||||
max_local_copy_mb = load_settings().runtime.max_local_copy_mb
|
|
||||||
max_copy_bytes = max_local_copy_mb * 1024 * 1024
|
|
||||||
oversized = find_oversized_local_targets(args.targets_info, max_copy_bytes)
|
|
||||||
if oversized:
|
|
||||||
details = "; ".join(
|
|
||||||
f"{path} ({size / (1024 * 1024):.0f} MB)" for path, size in oversized
|
|
||||||
)
|
|
||||||
parser.error(
|
|
||||||
f"Local target too large to stream into the sandbox: {details}. "
|
|
||||||
f"The limit is {max_local_copy_mb} MB "
|
|
||||||
"(set STRIX_MAX_LOCAL_COPY_MB to change it). Re-run with "
|
|
||||||
"--mount <path> to bind-mount the directory instead of copying it."
|
|
||||||
)
|
)
|
||||||
|
except ValueError:
|
||||||
|
parser.error(f"Invalid target '{target}'")
|
||||||
|
|
||||||
|
assign_workspace_subdirs(args.targets_info)
|
||||||
|
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
def _persist_run_record(args: argparse.Namespace) -> None:
|
|
||||||
run_dir = run_dir_for(args.run_name)
|
|
||||||
run_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
run_record = {
|
|
||||||
"run_id": args.run_name,
|
|
||||||
"run_name": args.run_name,
|
|
||||||
"status": "running",
|
|
||||||
"start_time": datetime.now(UTC).isoformat(),
|
|
||||||
"end_time": None,
|
|
||||||
"targets_info": args.targets_info,
|
|
||||||
"scan_mode": args.scan_mode,
|
|
||||||
"instruction": args.instruction,
|
|
||||||
"non_interactive": args.non_interactive,
|
|
||||||
"local_sources": getattr(args, "local_sources", []),
|
|
||||||
"diff_scope": getattr(args, "diff_scope", {"active": False}),
|
|
||||||
"scope_mode": args.scope_mode,
|
|
||||||
"diff_base": args.diff_base,
|
|
||||||
}
|
|
||||||
write_run_record(run_dir, run_record)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
|
|
||||||
"""Populate ``args.targets_info`` and friends from a prior run's run.json."""
|
|
||||||
run_dir = run_dir_for(args.resume)
|
|
||||||
state_path = run_dir / "run.json"
|
|
||||||
if not state_path.exists():
|
|
||||||
parser.error(
|
|
||||||
f"--resume {args.resume}: no such run "
|
|
||||||
f"(missing {state_path}; remove --resume for a fresh start)"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
state = read_run_record(run_dir)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
parser.error(f"--resume {args.resume}: run.json unreadable: {exc}")
|
|
||||||
|
|
||||||
args.targets_info = state.get("targets_info") or []
|
|
||||||
if not args.targets_info:
|
|
||||||
parser.error(f"--resume {args.resume}: run.json has no targets_info")
|
|
||||||
|
|
||||||
for target in args.targets_info:
|
|
||||||
if not isinstance(target, dict):
|
|
||||||
continue
|
|
||||||
details = target.get("details") or {}
|
|
||||||
if target.get("type") != "repository":
|
|
||||||
continue
|
|
||||||
cloned = details.get("cloned_repo_path")
|
|
||||||
if not cloned:
|
|
||||||
continue
|
|
||||||
if not Path(cloned).expanduser().exists():
|
|
||||||
parser.error(
|
|
||||||
f"--resume {args.resume}: cloned repo at {cloned} is missing. "
|
|
||||||
f"It was deleted between runs. Pick a fresh --run-name to "
|
|
||||||
f"re-clone, or restore the directory before resuming."
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.instruction is None:
|
|
||||||
args.instruction = state.get("instruction")
|
|
||||||
if state.get("local_sources"):
|
|
||||||
args.local_sources = state.get("local_sources")
|
|
||||||
if state.get("diff_scope"):
|
|
||||||
args.diff_scope = state.get("diff_scope")
|
|
||||||
persisted_scan_mode = state.get("scan_mode")
|
|
||||||
if persisted_scan_mode and args.scan_mode == "deep":
|
|
||||||
args.scan_mode = persisted_scan_mode
|
|
||||||
|
|
||||||
|
|
||||||
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
|
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
|
||||||
console = Console()
|
console = Console()
|
||||||
report_state = get_global_report_state()
|
tracer = get_global_tracer()
|
||||||
|
|
||||||
scan_completed = False
|
scan_completed = False
|
||||||
if report_state:
|
if tracer and tracer.scan_results:
|
||||||
scan_completed = report_state.run_record.get("status") == "completed"
|
scan_completed = tracer.scan_results.get("scan_completed", False)
|
||||||
|
|
||||||
completion_text = Text()
|
completion_text = Text()
|
||||||
if scan_completed:
|
if scan_completed:
|
||||||
@@ -642,9 +450,9 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
|||||||
target_text.append("\n ")
|
target_text.append("\n ")
|
||||||
target_text.append(target_info["original"], style="white")
|
target_text.append(target_info["original"], style="white")
|
||||||
|
|
||||||
stats_text = build_final_stats_text(report_state)
|
stats_text = build_final_stats_text(tracer)
|
||||||
|
|
||||||
panel_parts: list[Text | str] = [completion_text, "\n\n", target_text]
|
panel_parts = [completion_text, "\n\n", target_text]
|
||||||
|
|
||||||
if stats_text.plain:
|
if stats_text.plain:
|
||||||
panel_parts.extend(["\n", stats_text])
|
panel_parts.extend(["\n", stats_text])
|
||||||
@@ -656,14 +464,6 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
|||||||
results_text.append(str(results_path), style="#60a5fa")
|
results_text.append(str(results_path), style="#60a5fa")
|
||||||
panel_parts.extend(["\n", results_text])
|
panel_parts.extend(["\n", results_text])
|
||||||
|
|
||||||
if not scan_completed:
|
|
||||||
resume_text = Text()
|
|
||||||
resume_text.append("\n")
|
|
||||||
resume_text.append("Resume", style="dim")
|
|
||||||
resume_text.append(" ")
|
|
||||||
resume_text.append(f"strix --resume {args.run_name}", style="#22c55e")
|
|
||||||
panel_parts.extend(["\n", resume_text])
|
|
||||||
|
|
||||||
panel_content = Text.assemble(*panel_parts)
|
panel_content = Text.assemble(*panel_parts)
|
||||||
|
|
||||||
border_style = "#22c55e" if scan_completed else "#eab308"
|
border_style = "#22c55e" if scan_completed else "#eab308"
|
||||||
@@ -679,11 +479,7 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
|||||||
console.print("\n")
|
console.print("\n")
|
||||||
console.print(panel)
|
console.print(panel)
|
||||||
console.print()
|
console.print()
|
||||||
console.print(
|
console.print("[#60a5fa]strix.ai[/] [dim]·[/] [#60a5fa]discord.gg/strix-ai[/]")
|
||||||
"[#60a5fa]strix.ai[/] [dim]·[/] "
|
|
||||||
"[#60a5fa]docs.strix.ai[/] [dim]·[/] "
|
|
||||||
"[#60a5fa]discord.gg/strix-ai[/]"
|
|
||||||
)
|
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
@@ -691,15 +487,11 @@ def pull_docker_image() -> None:
|
|||||||
console = Console()
|
console = Console()
|
||||||
client = check_docker_connection()
|
client = check_docker_connection()
|
||||||
|
|
||||||
image = load_settings().runtime.image
|
if image_exists(client, Config.get("strix_image")): # type: ignore[arg-type]
|
||||||
|
|
||||||
if image_exists(client, image):
|
|
||||||
logger.debug("Docker image already present locally: %s", image)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Pulling docker image: %s", image)
|
|
||||||
console.print()
|
console.print()
|
||||||
console.print(f"[dim]Pulling image[/] {image}")
|
console.print(f"[dim]Pulling image[/] {Config.get('strix_image')}")
|
||||||
console.print("[dim yellow]This only happens on first run and may take a few minutes...[/]")
|
console.print("[dim yellow]This only happens on first run and may take a few minutes...[/]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
@@ -708,16 +500,15 @@ def pull_docker_image() -> None:
|
|||||||
layers_info: dict[str, str] = {}
|
layers_info: dict[str, str] = {}
|
||||||
last_update = ""
|
last_update = ""
|
||||||
|
|
||||||
for line in client.api.pull(image, stream=True, decode=True):
|
for line in client.api.pull(Config.get("strix_image"), stream=True, decode=True):
|
||||||
last_update = process_pull_line(line, layers_info, status, last_update)
|
last_update = process_pull_line(line, layers_info, status, last_update)
|
||||||
|
|
||||||
except DockerException as e:
|
except DockerException as e:
|
||||||
logger.exception("Failed to pull docker image %s", image)
|
|
||||||
console.print()
|
console.print()
|
||||||
error_text = Text()
|
error_text = Text()
|
||||||
error_text.append("FAILED TO PULL IMAGE", style="bold red")
|
error_text.append("FAILED TO PULL IMAGE", style="bold red")
|
||||||
error_text.append("\n\n", style="white")
|
error_text.append("\n\n", style="white")
|
||||||
error_text.append(f"Could not download: {image}\n", style="white")
|
error_text.append(f"Could not download: {Config.get('strix_image')}\n", style="white")
|
||||||
error_text.append(str(e), style="dim red")
|
error_text.append(str(e), style="dim red")
|
||||||
|
|
||||||
panel = Panel(
|
panel = Panel(
|
||||||
@@ -730,23 +521,30 @@ def pull_docker_image() -> None:
|
|||||||
console.print(panel, "\n")
|
console.print(panel, "\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
logger.info("Docker image %s ready", image)
|
|
||||||
success_text = Text()
|
success_text = Text()
|
||||||
success_text.append("Docker image ready", style="#22c55e")
|
success_text.append("Docker image ready", style="#22c55e")
|
||||||
console.print(success_text)
|
console.print(success_text)
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def apply_config_override(config_path: str) -> None:
|
||||||
configure_dependency_logging()
|
Config._config_file_override = validate_config_file(config_path)
|
||||||
|
apply_saved_config(force=True)
|
||||||
|
|
||||||
|
|
||||||
|
def persist_config() -> None:
|
||||||
|
if Config._config_file_override is None:
|
||||||
|
save_current_config()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None: # noqa: PLR0912, PLR0915
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||||
|
|
||||||
args = parse_arguments()
|
args = parse_arguments()
|
||||||
|
|
||||||
if args.config:
|
if args.config:
|
||||||
apply_config_override(validate_config_file(args.config))
|
apply_config_override(args.config)
|
||||||
|
|
||||||
check_docker_installed()
|
check_docker_installed()
|
||||||
pull_docker_image()
|
pull_docker_image()
|
||||||
@@ -754,63 +552,60 @@ def main() -> None:
|
|||||||
validate_environment()
|
validate_environment()
|
||||||
asyncio.run(warm_up_llm())
|
asyncio.run(warm_up_llm())
|
||||||
|
|
||||||
persist_current()
|
persist_config()
|
||||||
|
|
||||||
args.run_name = args.resume or generate_run_name(args.targets_info)
|
args.run_name = generate_run_name(args.targets_info)
|
||||||
|
|
||||||
if not args.resume:
|
for target_info in args.targets_info:
|
||||||
for target_info in args.targets_info:
|
if target_info["type"] == "repository":
|
||||||
if target_info["type"] == "repository":
|
repo_url = target_info["details"]["target_repo"]
|
||||||
repo_url = target_info["details"]["target_repo"]
|
dest_name = target_info["details"].get("workspace_subdir")
|
||||||
dest_name = target_info["details"].get("workspace_subdir")
|
cloned_path = clone_repository(repo_url, args.run_name, dest_name)
|
||||||
cloned_path = clone_repository(repo_url, args.run_name, dest_name)
|
target_info["details"]["cloned_repo_path"] = cloned_path
|
||||||
target_info["details"]["cloned_repo_path"] = cloned_path
|
|
||||||
|
|
||||||
args.local_sources = collect_local_sources(args.targets_info)
|
args.local_sources = collect_local_sources(args.targets_info)
|
||||||
try:
|
try:
|
||||||
diff_scope = resolve_diff_scope_context(
|
diff_scope = resolve_diff_scope_context(
|
||||||
local_sources=args.local_sources,
|
local_sources=args.local_sources,
|
||||||
scope_mode=args.scope_mode,
|
scope_mode=args.scope_mode,
|
||||||
diff_base=args.diff_base,
|
diff_base=args.diff_base,
|
||||||
non_interactive=args.non_interactive,
|
non_interactive=args.non_interactive,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
console = Console()
|
console = Console()
|
||||||
error_text = Text()
|
error_text = Text()
|
||||||
error_text.append("DIFF SCOPE RESOLUTION FAILED", style="bold red")
|
error_text.append("DIFF SCOPE RESOLUTION FAILED", style="bold red")
|
||||||
error_text.append("\n\n", style="white")
|
error_text.append("\n\n", style="white")
|
||||||
error_text.append(str(e), style="white")
|
error_text.append(str(e), style="white")
|
||||||
|
|
||||||
panel = Panel(
|
panel = Panel(
|
||||||
error_text,
|
error_text,
|
||||||
title="[bold white]STRIX",
|
title="[bold white]STRIX",
|
||||||
title_align="left",
|
title_align="left",
|
||||||
border_style="red",
|
border_style="red",
|
||||||
padding=(1, 2),
|
padding=(1, 2),
|
||||||
)
|
)
|
||||||
console.print("\n")
|
console.print("\n")
|
||||||
console.print(panel)
|
console.print(panel)
|
||||||
console.print()
|
console.print()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
args.diff_scope = diff_scope.metadata
|
args.diff_scope = diff_scope.metadata
|
||||||
if diff_scope.instruction_block:
|
if diff_scope.instruction_block:
|
||||||
if args.instruction:
|
if args.instruction:
|
||||||
args.instruction = f"{diff_scope.instruction_block}\n\n{args.instruction}"
|
args.instruction = f"{diff_scope.instruction_block}\n\n{args.instruction}"
|
||||||
else:
|
else:
|
||||||
args.instruction = diff_scope.instruction_block
|
args.instruction = diff_scope.instruction_block
|
||||||
|
|
||||||
_persist_run_record(args)
|
is_whitebox = bool(args.local_sources)
|
||||||
|
|
||||||
_telemetry_start_kwargs = {
|
posthog.start(
|
||||||
"model": load_settings().llm.model,
|
model=Config.get("strix_llm"),
|
||||||
"scan_mode": args.scan_mode,
|
scan_mode=args.scan_mode,
|
||||||
"is_whitebox": is_whitebox_scan(args.targets_info),
|
is_whitebox=is_whitebox,
|
||||||
"interactive": not args.non_interactive,
|
interactive=not args.non_interactive,
|
||||||
"has_instructions": bool(args.instruction),
|
has_instructions=bool(args.instruction),
|
||||||
}
|
)
|
||||||
posthog.start(**_telemetry_start_kwargs)
|
|
||||||
scarf.start(**_telemetry_start_kwargs)
|
|
||||||
|
|
||||||
exit_reason = "user_exit"
|
exit_reason = "user_exit"
|
||||||
try:
|
try:
|
||||||
@@ -820,28 +615,21 @@ def main() -> None:
|
|||||||
asyncio.run(run_tui(args))
|
asyncio.run(run_tui(args))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
exit_reason = "interrupted"
|
exit_reason = "interrupted"
|
||||||
except Exception:
|
except Exception as e:
|
||||||
exit_reason = "error"
|
exit_reason = "error"
|
||||||
posthog.error("unhandled_exception")
|
posthog.error("unhandled_exception", str(e))
|
||||||
scarf.error("unhandled_exception")
|
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
report_state = get_global_report_state()
|
tracer = get_global_tracer()
|
||||||
if report_state:
|
if tracer:
|
||||||
status = {"interrupted": "interrupted", "error": "failed"}.get(
|
posthog.end(tracer, exit_reason=exit_reason)
|
||||||
exit_reason,
|
|
||||||
"stopped",
|
|
||||||
)
|
|
||||||
report_state.cleanup(status=status)
|
|
||||||
posthog.end(report_state, exit_reason=exit_reason)
|
|
||||||
scarf.end(report_state, exit_reason=exit_reason)
|
|
||||||
|
|
||||||
results_path = run_dir_for(args.run_name)
|
results_path = Path("strix_runs") / args.run_name
|
||||||
display_completion_message(args, results_path)
|
display_completion_message(args, results_path)
|
||||||
|
|
||||||
if args.non_interactive:
|
if args.non_interactive:
|
||||||
report_state = get_global_report_state()
|
tracer = get_global_tracer()
|
||||||
if report_state and report_state.vulnerability_reports:
|
if tracer and tracer.vulnerability_reports:
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import html
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from strix.llm.utils import normalize_tool_format
|
||||||
|
|
||||||
|
|
||||||
|
_FUNCTION_TAG_PREFIX = "<function="
|
||||||
|
_INVOKE_TAG_PREFIX = "<invoke "
|
||||||
|
|
||||||
|
_FUNC_PATTERN = re.compile(r"<function=([^>]+)>")
|
||||||
|
_FUNC_END_PATTERN = re.compile(r"</function>")
|
||||||
|
_COMPLETE_PARAM_PATTERN = re.compile(r"<parameter=([^>]+)>(.*?)</parameter>", re.DOTALL)
|
||||||
|
_INCOMPLETE_PARAM_PATTERN = re.compile(r"<parameter=([^>]+)>(.*)$", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_safe_content(content: str) -> tuple[str, str]:
|
||||||
|
if not content:
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
last_lt = content.rfind("<")
|
||||||
|
if last_lt == -1:
|
||||||
|
return content, ""
|
||||||
|
|
||||||
|
suffix = content[last_lt:]
|
||||||
|
|
||||||
|
if _FUNCTION_TAG_PREFIX.startswith(suffix) or _INVOKE_TAG_PREFIX.startswith(suffix):
|
||||||
|
return content[:last_lt], suffix
|
||||||
|
|
||||||
|
return content, ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamSegment:
|
||||||
|
type: Literal["text", "tool"]
|
||||||
|
content: str
|
||||||
|
tool_name: str | None = None
|
||||||
|
args: dict[str, str] | None = None
|
||||||
|
is_complete: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_streaming_content(content: str) -> list[StreamSegment]:
|
||||||
|
if not content:
|
||||||
|
return []
|
||||||
|
|
||||||
|
content = normalize_tool_format(content)
|
||||||
|
|
||||||
|
segments: list[StreamSegment] = []
|
||||||
|
|
||||||
|
func_matches = list(_FUNC_PATTERN.finditer(content))
|
||||||
|
|
||||||
|
if not func_matches:
|
||||||
|
safe_content, _ = _get_safe_content(content)
|
||||||
|
text = safe_content.strip()
|
||||||
|
if text:
|
||||||
|
segments.append(StreamSegment(type="text", content=text))
|
||||||
|
return segments
|
||||||
|
|
||||||
|
first_func_start = func_matches[0].start()
|
||||||
|
if first_func_start > 0:
|
||||||
|
text_before = content[:first_func_start].strip()
|
||||||
|
if text_before:
|
||||||
|
segments.append(StreamSegment(type="text", content=text_before))
|
||||||
|
|
||||||
|
for i, match in enumerate(func_matches):
|
||||||
|
tool_name = match.group(1)
|
||||||
|
func_start = match.end()
|
||||||
|
|
||||||
|
func_end_match = _FUNC_END_PATTERN.search(content, func_start)
|
||||||
|
|
||||||
|
if func_end_match:
|
||||||
|
func_body = content[func_start : func_end_match.start()]
|
||||||
|
is_complete = True
|
||||||
|
end_pos = func_end_match.end()
|
||||||
|
else:
|
||||||
|
if i + 1 < len(func_matches):
|
||||||
|
next_func_start = func_matches[i + 1].start()
|
||||||
|
func_body = content[func_start:next_func_start]
|
||||||
|
else:
|
||||||
|
func_body = content[func_start:]
|
||||||
|
is_complete = False
|
||||||
|
end_pos = len(content)
|
||||||
|
|
||||||
|
args = _parse_streaming_params(func_body)
|
||||||
|
|
||||||
|
segments.append(
|
||||||
|
StreamSegment(
|
||||||
|
type="tool",
|
||||||
|
content=func_body,
|
||||||
|
tool_name=tool_name,
|
||||||
|
args=args,
|
||||||
|
is_complete=is_complete,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_complete and i + 1 < len(func_matches):
|
||||||
|
next_start = func_matches[i + 1].start()
|
||||||
|
text_between = content[end_pos:next_start].strip()
|
||||||
|
if text_between:
|
||||||
|
segments.append(StreamSegment(type="text", content=text_between))
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_streaming_params(func_body: str) -> dict[str, str]:
|
||||||
|
args: dict[str, str] = {}
|
||||||
|
|
||||||
|
complete_matches = list(_COMPLETE_PARAM_PATTERN.finditer(func_body))
|
||||||
|
complete_end_pos = 0
|
||||||
|
|
||||||
|
for match in complete_matches:
|
||||||
|
param_name = match.group(1)
|
||||||
|
param_value = html.unescape(match.group(2).strip())
|
||||||
|
args[param_name] = param_value
|
||||||
|
complete_end_pos = max(complete_end_pos, match.end())
|
||||||
|
|
||||||
|
remaining = func_body[complete_end_pos:]
|
||||||
|
incomplete_match = _INCOMPLETE_PARAM_PATTERN.search(remaining)
|
||||||
|
if incomplete_match:
|
||||||
|
param_name = incomplete_match.group(1)
|
||||||
|
param_value = html.unescape(incomplete_match.group(2).strip())
|
||||||
|
args[param_name] = param_value
|
||||||
|
|
||||||
|
return args
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from . import (
|
||||||
|
agent_message_renderer,
|
||||||
|
agents_graph_renderer,
|
||||||
|
browser_renderer,
|
||||||
|
file_edit_renderer,
|
||||||
|
finish_renderer,
|
||||||
|
load_skill_renderer,
|
||||||
|
notes_renderer,
|
||||||
|
proxy_renderer,
|
||||||
|
python_renderer,
|
||||||
|
reporting_renderer,
|
||||||
|
scan_info_renderer,
|
||||||
|
terminal_renderer,
|
||||||
|
thinking_renderer,
|
||||||
|
todo_renderer,
|
||||||
|
user_message_renderer,
|
||||||
|
web_search_renderer,
|
||||||
|
)
|
||||||
|
from .base_renderer import BaseToolRenderer
|
||||||
|
from .registry import ToolTUIRegistry, get_tool_renderer, register_tool_renderer, render_tool_widget
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseToolRenderer",
|
||||||
|
"ToolTUIRegistry",
|
||||||
|
"agent_message_renderer",
|
||||||
|
"agents_graph_renderer",
|
||||||
|
"browser_renderer",
|
||||||
|
"file_edit_renderer",
|
||||||
|
"finish_renderer",
|
||||||
|
"get_tool_renderer",
|
||||||
|
"load_skill_renderer",
|
||||||
|
"notes_renderer",
|
||||||
|
"proxy_renderer",
|
||||||
|
"python_renderer",
|
||||||
|
"register_tool_renderer",
|
||||||
|
"render_tool_widget",
|
||||||
|
"reporting_renderer",
|
||||||
|
"scan_info_renderer",
|
||||||
|
"terminal_renderer",
|
||||||
|
"thinking_renderer",
|
||||||
|
"todo_renderer",
|
||||||
|
"user_message_renderer",
|
||||||
|
"web_search_renderer",
|
||||||
|
]
|
||||||
+25
-6
@@ -1,14 +1,14 @@
|
|||||||
import re
|
|
||||||
from functools import cache
|
from functools import cache
|
||||||
from typing import Any
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from pygments.lexers import get_lexer_by_name, guess_lexer
|
from pygments.lexers import get_lexer_by_name, guess_lexer
|
||||||
from pygments.styles import get_style_by_name
|
from pygments.styles import get_style_by_name
|
||||||
from pygments.util import ClassNotFound
|
from pygments.util import ClassNotFound
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from .base_renderer import BaseToolRenderer
|
||||||
_BLANK_LINE_RUNS = re.compile(r"\n\s*\n")
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
_HEADER_STYLES = [
|
_HEADER_STYLES = [
|
||||||
@@ -160,12 +160,31 @@ def _process_inline_formatting(line: str) -> Text:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class AgentMessageRenderer:
|
@register_tool_renderer
|
||||||
|
class AgentMessageRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "agent_message"
|
||||||
|
css_classes: ClassVar[list[str]] = ["chat-message", "agent-message"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
content = tool_data.get("content", "")
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
return Static(Text(), classes=" ".join(cls.css_classes))
|
||||||
|
|
||||||
|
styled_text = _apply_markdown_styles(content)
|
||||||
|
|
||||||
|
return Static(styled_text, classes=" ".join(cls.css_classes))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def render_simple(cls, content: str) -> Text:
|
def render_simple(cls, content: str) -> Text:
|
||||||
if not content:
|
if not content:
|
||||||
return Text()
|
return Text()
|
||||||
cleaned = _BLANK_LINE_RUNS.sub("\n\n", content).strip()
|
|
||||||
|
from strix.llm.utils import clean_content
|
||||||
|
|
||||||
|
cleaned = clean_content(content)
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
return Text()
|
return Text()
|
||||||
|
|
||||||
return _apply_markdown_styles(cleaned)
|
return _apply_markdown_styles(cleaned)
|
||||||
+3
-38
@@ -61,12 +61,12 @@ class SendMessageToAgentRenderer(BaseToolRenderer):
|
|||||||
status = tool_data.get("status", "unknown")
|
status = tool_data.get("status", "unknown")
|
||||||
|
|
||||||
message = args.get("message", "")
|
message = args.get("message", "")
|
||||||
target_agent_id = args.get("target_agent_id", "")
|
agent_id = args.get("agent_id", "")
|
||||||
|
|
||||||
text = Text()
|
text = Text()
|
||||||
text.append("→ ", style="#60a5fa")
|
text.append("→ ", style="#60a5fa")
|
||||||
if target_agent_id:
|
if agent_id:
|
||||||
text.append(f"to {target_agent_id}", style="dim")
|
text.append(f"to {agent_id}", style="dim")
|
||||||
else:
|
else:
|
||||||
text.append("sending message", style="dim")
|
text.append("sending message", style="dim")
|
||||||
|
|
||||||
@@ -138,38 +138,3 @@ class WaitForMessageRenderer(BaseToolRenderer):
|
|||||||
|
|
||||||
css_classes = cls.get_css_classes(status)
|
css_classes = cls.get_css_classes(status)
|
||||||
return Static(text, classes=css_classes)
|
return Static(text, classes=css_classes)
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
|
||||||
class StopAgentRenderer(BaseToolRenderer):
|
|
||||||
tool_name: ClassVar[str] = "stop_agent"
|
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
|
||||||
args = tool_data.get("args", {})
|
|
||||||
result = tool_data.get("result")
|
|
||||||
status = tool_data.get("status", "unknown")
|
|
||||||
|
|
||||||
target_agent_id = args.get("target_agent_id", "")
|
|
||||||
cascade = args.get("cascade", True)
|
|
||||||
reason = args.get("reason", "")
|
|
||||||
|
|
||||||
text = Text()
|
|
||||||
text.append("◼ ", style="#ef4444")
|
|
||||||
text.append("stopping", style="dim")
|
|
||||||
if target_agent_id:
|
|
||||||
text.append(f" {target_agent_id}", style="bold #ef4444")
|
|
||||||
if cascade:
|
|
||||||
text.append(" + descendants", style="dim italic")
|
|
||||||
|
|
||||||
if reason:
|
|
||||||
text.append("\n ")
|
|
||||||
text.append(reason, style="dim")
|
|
||||||
|
|
||||||
if isinstance(result, dict) and result.get("success") is False and result.get("error"):
|
|
||||||
text.append("\n ")
|
|
||||||
text.append(str(result["error"]), style="#ef4444")
|
|
||||||
|
|
||||||
css_classes = cls.get_css_classes(status)
|
|
||||||
return Static(text, classes=css_classes)
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
|
||||||
|
class BaseToolRenderer(ABC):
|
||||||
|
tool_name: ClassVar[str] = ""
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_text(cls, tool_data: dict[str, Any]) -> Text: # noqa: ARG003
|
||||||
|
return Text()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_static(cls, content: Text, status: str) -> Static:
|
||||||
|
css_classes = cls.get_css_classes(status)
|
||||||
|
return Static(content, classes=css_classes)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def status_icon(cls, status: str) -> tuple[str, str]:
|
||||||
|
icons = {
|
||||||
|
"running": ("● In progress...", "#f59e0b"),
|
||||||
|
"completed": ("✓ Done", "#22c55e"),
|
||||||
|
"failed": ("✗ Failed", "#dc2626"),
|
||||||
|
"error": ("✗ Error", "#dc2626"),
|
||||||
|
}
|
||||||
|
return icons.get(status, ("○ Unknown", "dim"))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_css_classes(cls, status: str) -> str:
|
||||||
|
base_classes = cls.css_classes.copy()
|
||||||
|
base_classes.append(f"status-{status}")
|
||||||
|
return " ".join(base_classes)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def text_with_style(cls, content: str, style: str | None = None) -> Text:
|
||||||
|
text = Text()
|
||||||
|
text.append(content, style=style)
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def text_icon_label(
|
||||||
|
cls,
|
||||||
|
icon: str,
|
||||||
|
label: str,
|
||||||
|
icon_style: str | None = None,
|
||||||
|
label_style: str | None = None,
|
||||||
|
) -> Text:
|
||||||
|
text = Text()
|
||||||
|
text.append(icon, style=icon_style)
|
||||||
|
text.append(" ")
|
||||||
|
text.append(label, style=label_style)
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def text_header(
|
||||||
|
cls,
|
||||||
|
icon: str,
|
||||||
|
title: str,
|
||||||
|
subtitle: str = "",
|
||||||
|
title_style: str = "bold",
|
||||||
|
subtitle_style: str = "dim",
|
||||||
|
) -> Text:
|
||||||
|
text = Text()
|
||||||
|
text.append(icon)
|
||||||
|
text.append(" ")
|
||||||
|
text.append(title, style=title_style)
|
||||||
|
if subtitle:
|
||||||
|
text.append(" ")
|
||||||
|
text.append(subtitle, style=subtitle_style)
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def text_key_value(
|
||||||
|
cls,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
key_style: str = "dim",
|
||||||
|
value_style: str | None = None,
|
||||||
|
indent: int = 2,
|
||||||
|
) -> Text:
|
||||||
|
text = Text()
|
||||||
|
text.append(" " * indent)
|
||||||
|
text.append(key, style=key_style)
|
||||||
|
text.append(": ")
|
||||||
|
text.append(value, style=value_style)
|
||||||
|
return text
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from functools import cache
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from pygments.lexers import get_lexer_by_name
|
||||||
|
from pygments.styles import get_style_by_name
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from .base_renderer import BaseToolRenderer
|
||||||
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _get_style_colors() -> dict[Any, str]:
|
||||||
|
style = get_style_by_name("native")
|
||||||
|
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class BrowserRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "browser_action"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "browser-tool"]
|
||||||
|
|
||||||
|
SIMPLE_ACTIONS: ClassVar[dict[str, str]] = {
|
||||||
|
"back": "going back in browser history",
|
||||||
|
"forward": "going forward in browser history",
|
||||||
|
"scroll_down": "scrolling down",
|
||||||
|
"scroll_up": "scrolling up",
|
||||||
|
"refresh": "refreshing browser tab",
|
||||||
|
"close_tab": "closing browser tab",
|
||||||
|
"switch_tab": "switching browser tab",
|
||||||
|
"list_tabs": "listing browser tabs",
|
||||||
|
"view_source": "viewing page source",
|
||||||
|
"get_console_logs": "getting console logs",
|
||||||
|
"screenshot": "taking screenshot of browser tab",
|
||||||
|
"wait": "waiting...",
|
||||||
|
"close": "closing browser",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_token_color(cls, token_type: Any) -> str | None:
|
||||||
|
colors = _get_style_colors()
|
||||||
|
while token_type:
|
||||||
|
if token_type in colors:
|
||||||
|
return colors[token_type]
|
||||||
|
token_type = token_type.parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _highlight_js(cls, code: str) -> Text:
|
||||||
|
lexer = get_lexer_by_name("javascript")
|
||||||
|
text = Text()
|
||||||
|
|
||||||
|
for token_type, token_value in lexer.get_tokens(code):
|
||||||
|
if not token_value:
|
||||||
|
continue
|
||||||
|
color = cls._get_token_color(token_type)
|
||||||
|
text.append(token_value, style=color)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
args = tool_data.get("args", {})
|
||||||
|
status = tool_data.get("status", "unknown")
|
||||||
|
|
||||||
|
action = args.get("action", "")
|
||||||
|
content = cls._build_content(action, args)
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes(status)
|
||||||
|
return Static(content, classes=css_classes)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_url_action(cls, text: Text, label: str, url: str | None, suffix: str = "") -> None:
|
||||||
|
text.append(label, style="#06b6d4")
|
||||||
|
if url:
|
||||||
|
text.append(url, style="#06b6d4")
|
||||||
|
if suffix:
|
||||||
|
text.append(suffix, style="#06b6d4")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_content(cls, action: str, args: dict[str, Any]) -> Text:
|
||||||
|
text = Text()
|
||||||
|
text.append("🌐 ")
|
||||||
|
|
||||||
|
if action in cls.SIMPLE_ACTIONS:
|
||||||
|
text.append(cls.SIMPLE_ACTIONS[action], style="#06b6d4")
|
||||||
|
return text
|
||||||
|
|
||||||
|
url = args.get("url")
|
||||||
|
|
||||||
|
url_actions = {
|
||||||
|
"launch": ("launching ", " on browser" if url else "browser"),
|
||||||
|
"goto": ("navigating to ", ""),
|
||||||
|
"new_tab": ("opening tab ", ""),
|
||||||
|
}
|
||||||
|
if action in url_actions:
|
||||||
|
label, suffix = url_actions[action]
|
||||||
|
if action == "launch" and not url:
|
||||||
|
text.append("launching browser", style="#06b6d4")
|
||||||
|
else:
|
||||||
|
cls._build_url_action(text, label, url, suffix)
|
||||||
|
return text
|
||||||
|
|
||||||
|
click_actions = {
|
||||||
|
"click": "clicking",
|
||||||
|
"double_click": "double clicking",
|
||||||
|
"hover": "hovering",
|
||||||
|
}
|
||||||
|
if action in click_actions:
|
||||||
|
text.append(click_actions[action], style="#06b6d4")
|
||||||
|
return text
|
||||||
|
|
||||||
|
handlers: dict[str, tuple[str, str | None]] = {
|
||||||
|
"type": ("typing ", args.get("text")),
|
||||||
|
"press_key": ("pressing key ", args.get("key")),
|
||||||
|
"save_pdf": ("saving PDF to ", args.get("file_path")),
|
||||||
|
}
|
||||||
|
if action in handlers:
|
||||||
|
label, value = handlers[action]
|
||||||
|
text.append(label, style="#06b6d4")
|
||||||
|
if value:
|
||||||
|
text.append(str(value), style="#06b6d4")
|
||||||
|
return text
|
||||||
|
|
||||||
|
if action == "execute_js":
|
||||||
|
text.append("executing javascript", style="#06b6d4")
|
||||||
|
js_code = args.get("js_code")
|
||||||
|
if js_code:
|
||||||
|
text.append("\n")
|
||||||
|
text.append_text(cls._highlight_js(js_code))
|
||||||
|
return text
|
||||||
|
|
||||||
|
if action:
|
||||||
|
text.append(action, style="#06b6d4")
|
||||||
|
return text
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
from functools import cache
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from pygments.lexers import get_lexer_by_name, get_lexer_for_filename
|
||||||
|
from pygments.styles import get_style_by_name
|
||||||
|
from pygments.util import ClassNotFound
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from .base_renderer import BaseToolRenderer
|
||||||
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _get_style_colors() -> dict[Any, str]:
|
||||||
|
style = get_style_by_name("native")
|
||||||
|
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_lexer_for_file(path: str) -> Any:
|
||||||
|
try:
|
||||||
|
return get_lexer_for_filename(path)
|
||||||
|
except ClassNotFound:
|
||||||
|
return get_lexer_by_name("text")
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class StrReplaceEditorRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "str_replace_editor"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_token_color(cls, token_type: Any) -> str | None:
|
||||||
|
colors = _get_style_colors()
|
||||||
|
while token_type:
|
||||||
|
if token_type in colors:
|
||||||
|
return colors[token_type]
|
||||||
|
token_type = token_type.parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _highlight_code(cls, code: str, path: str) -> Text:
|
||||||
|
lexer = _get_lexer_for_file(path)
|
||||||
|
text = Text()
|
||||||
|
|
||||||
|
for token_type, token_value in lexer.get_tokens(code):
|
||||||
|
if not token_value:
|
||||||
|
continue
|
||||||
|
color = cls._get_token_color(token_type)
|
||||||
|
text.append(token_value, style=color)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
args = tool_data.get("args", {})
|
||||||
|
result = tool_data.get("result")
|
||||||
|
|
||||||
|
command = args.get("command", "")
|
||||||
|
path = args.get("path", "")
|
||||||
|
old_str = args.get("old_str", "")
|
||||||
|
new_str = args.get("new_str", "")
|
||||||
|
file_text = args.get("file_text", "")
|
||||||
|
|
||||||
|
text = Text()
|
||||||
|
|
||||||
|
icons_and_labels = {
|
||||||
|
"view": ("◇ ", "read", "#10b981"),
|
||||||
|
"str_replace": ("◇ ", "edit", "#10b981"),
|
||||||
|
"create": ("◇ ", "create", "#10b981"),
|
||||||
|
"insert": ("◇ ", "insert", "#10b981"),
|
||||||
|
"undo_edit": ("◇ ", "undo", "#10b981"),
|
||||||
|
}
|
||||||
|
|
||||||
|
icon, label, color = icons_and_labels.get(command, ("◇ ", "file", "#10b981"))
|
||||||
|
text.append(icon, style=color)
|
||||||
|
text.append(label, style="dim")
|
||||||
|
|
||||||
|
if path:
|
||||||
|
path_display = path[-60:] if len(path) > 60 else path
|
||||||
|
text.append(" ")
|
||||||
|
text.append(path_display, style="dim")
|
||||||
|
|
||||||
|
if command == "str_replace" and (old_str or new_str):
|
||||||
|
if old_str:
|
||||||
|
highlighted_old = cls._highlight_code(old_str, path)
|
||||||
|
for line in highlighted_old.plain.split("\n"):
|
||||||
|
text.append("\n")
|
||||||
|
text.append("-", style="#ef4444")
|
||||||
|
text.append(" ")
|
||||||
|
text.append(line)
|
||||||
|
|
||||||
|
if new_str:
|
||||||
|
highlighted_new = cls._highlight_code(new_str, path)
|
||||||
|
for line in highlighted_new.plain.split("\n"):
|
||||||
|
text.append("\n")
|
||||||
|
text.append("+", style="#22c55e")
|
||||||
|
text.append(" ")
|
||||||
|
text.append(line)
|
||||||
|
|
||||||
|
elif command == "create" and file_text:
|
||||||
|
text.append("\n")
|
||||||
|
text.append_text(cls._highlight_code(file_text, path))
|
||||||
|
|
||||||
|
elif command == "insert" and new_str:
|
||||||
|
highlighted_new = cls._highlight_code(new_str, path)
|
||||||
|
for line in highlighted_new.plain.split("\n"):
|
||||||
|
text.append("\n")
|
||||||
|
text.append("+", style="#22c55e")
|
||||||
|
text.append(" ")
|
||||||
|
text.append(line)
|
||||||
|
|
||||||
|
elif isinstance(result, str) and result.strip():
|
||||||
|
text.append("\n ")
|
||||||
|
text.append(result.strip(), style="dim")
|
||||||
|
elif not (result and isinstance(result, dict) and "content" in result) and not path:
|
||||||
|
text.append(" ")
|
||||||
|
text.append("Processing...", style="dim")
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes("completed")
|
||||||
|
return Static(text, classes=css_classes)
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class ListFilesRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "list_files"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
args = tool_data.get("args", {})
|
||||||
|
path = args.get("path", "")
|
||||||
|
|
||||||
|
text = Text()
|
||||||
|
text.append("◇ ", style="#10b981")
|
||||||
|
text.append("list", style="dim")
|
||||||
|
text.append(" ")
|
||||||
|
|
||||||
|
if path:
|
||||||
|
path_display = path[-60:] if len(path) > 60 else path
|
||||||
|
text.append(path_display, style="dim")
|
||||||
|
else:
|
||||||
|
text.append("Current directory", style="dim")
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes("completed")
|
||||||
|
return Static(text, classes=css_classes)
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class SearchFilesRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "search_files"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
args = tool_data.get("args", {})
|
||||||
|
path = args.get("path", "")
|
||||||
|
regex = args.get("regex", "")
|
||||||
|
|
||||||
|
text = Text()
|
||||||
|
text.append("◇ ", style="#a855f7")
|
||||||
|
text.append("search", style="dim")
|
||||||
|
text.append(" ")
|
||||||
|
|
||||||
|
if path and regex:
|
||||||
|
text.append(path, style="dim")
|
||||||
|
text.append(" ", style="dim")
|
||||||
|
text.append(regex, style="#a855f7")
|
||||||
|
elif path:
|
||||||
|
text.append(path, style="dim")
|
||||||
|
elif regex:
|
||||||
|
text.append(regex, style="#a855f7")
|
||||||
|
else:
|
||||||
|
text.append("...", style="dim")
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes("completed")
|
||||||
|
return Static(text, classes=css_classes)
|
||||||
+1
-5
@@ -17,11 +17,7 @@ class LoadSkillRenderer(BaseToolRenderer):
|
|||||||
args = tool_data.get("args", {})
|
args = tool_data.get("args", {})
|
||||||
status = tool_data.get("status", "completed")
|
status = tool_data.get("status", "completed")
|
||||||
|
|
||||||
raw_skills = args.get("skills", "")
|
requested = args.get("skills", "")
|
||||||
if isinstance(raw_skills, list):
|
|
||||||
requested = ", ".join(str(s) for s in raw_skills)
|
|
||||||
else:
|
|
||||||
requested = str(raw_skills)
|
|
||||||
|
|
||||||
text = Text()
|
text = Text()
|
||||||
text.append("◇ ", style="#10b981")
|
text.append("◇ ", style="#10b981")
|
||||||
+267
-193
@@ -17,6 +17,7 @@ def _truncate(text: str, max_len: int = 80) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _sanitize(text: str, max_len: int = 150) -> str:
|
def _sanitize(text: str, max_len: int = 150) -> str:
|
||||||
|
"""Remove newlines and truncate text."""
|
||||||
clean = text.replace("\n", " ").replace("\r", "").replace("\t", " ")
|
clean = text.replace("\n", " ").replace("\r", "").replace("\t", " ")
|
||||||
return _truncate(clean, max_len)
|
return _truncate(clean, max_len)
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ class ListRequestsRenderer(BaseToolRenderer):
|
|||||||
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
|
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
|
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912 # noqa: PLR0912
|
||||||
args = tool_data.get("args", {})
|
args = tool_data.get("args", {})
|
||||||
result = tool_data.get("result")
|
result = tool_data.get("result")
|
||||||
status = tool_data.get("status", "running")
|
status = tool_data.get("status", "running")
|
||||||
@@ -72,25 +73,21 @@ class ListRequestsRenderer(BaseToolRenderer):
|
|||||||
if "error" in result:
|
if "error" in result:
|
||||||
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
||||||
else:
|
else:
|
||||||
entries = result.get("entries", [])
|
total = result.get("total_count", 0)
|
||||||
page_info = result.get("page_info") or {}
|
requests = result.get("requests", [])
|
||||||
has_more = (
|
|
||||||
bool(page_info.get("has_next_page")) if isinstance(page_info, dict) else False
|
|
||||||
)
|
|
||||||
count_suffix = "+" if has_more else ""
|
|
||||||
text.append(f" [{len(entries)}{count_suffix} found]", style="dim")
|
|
||||||
|
|
||||||
if entries and isinstance(entries, list):
|
text.append(f" [{total} found]", style="dim")
|
||||||
|
|
||||||
|
if requests and isinstance(requests, list):
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
for i, entry in enumerate(entries[:MAX_REQUESTS_DISPLAY]):
|
for i, req in enumerate(requests[:MAX_REQUESTS_DISPLAY]):
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(req, dict):
|
||||||
continue
|
continue
|
||||||
req = entry.get("request") or {}
|
method = req.get("method", "?")
|
||||||
resp = entry.get("response") or {}
|
host = req.get("host", "")
|
||||||
method = req.get("method", "?") if isinstance(req, dict) else "?"
|
path = req.get("path", "/")
|
||||||
host = req.get("host", "") if isinstance(req, dict) else ""
|
resp = req.get("response") or {}
|
||||||
path = req.get("path", "/") if isinstance(req, dict) else "/"
|
code = resp.get("statusCode") if isinstance(resp, dict) else None
|
||||||
code = resp.get("status_code") if isinstance(resp, dict) else None
|
|
||||||
|
|
||||||
text.append(" ")
|
text.append(" ")
|
||||||
text.append(f"{method:6}", style="#a78bfa")
|
text.append(f"{method:6}", style="#a78bfa")
|
||||||
@@ -98,13 +95,13 @@ class ListRequestsRenderer(BaseToolRenderer):
|
|||||||
if code:
|
if code:
|
||||||
text.append(f" {code}", style=_status_style(code))
|
text.append(f" {code}", style=_status_style(code))
|
||||||
|
|
||||||
if i < min(len(entries), MAX_REQUESTS_DISPLAY) - 1:
|
if i < min(len(requests), MAX_REQUESTS_DISPLAY) - 1:
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
|
|
||||||
if len(entries) > MAX_REQUESTS_DISPLAY:
|
if len(requests) > MAX_REQUESTS_DISPLAY:
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
text.append(
|
text.append(
|
||||||
f" ... +{len(entries) - MAX_REQUESTS_DISPLAY} more",
|
f" ... +{len(requests) - MAX_REQUESTS_DISPLAY} more",
|
||||||
style="dim italic",
|
style="dim italic",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -142,14 +139,14 @@ class ViewRequestRenderer(BaseToolRenderer):
|
|||||||
if status == "completed" and isinstance(result, dict):
|
if status == "completed" and isinstance(result, dict):
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
||||||
elif "hits" in result:
|
elif "matches" in result:
|
||||||
hits = result.get("hits", [])
|
matches = result.get("matches", [])
|
||||||
total = result.get("total_hits", len(hits))
|
total = result.get("total_matches", len(matches))
|
||||||
text.append(f" [{total} matches]", style="dim")
|
text.append(f" [{total} matches]", style="dim")
|
||||||
|
|
||||||
if hits and isinstance(hits, list):
|
if matches and isinstance(matches, list):
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
for i, m in enumerate(hits[:5]):
|
for i, m in enumerate(matches[:5]):
|
||||||
if not isinstance(m, dict):
|
if not isinstance(m, dict):
|
||||||
continue
|
continue
|
||||||
before = m.get("before", "") or ""
|
before = m.get("before", "") or ""
|
||||||
@@ -167,20 +164,19 @@ class ViewRequestRenderer(BaseToolRenderer):
|
|||||||
if after:
|
if after:
|
||||||
text.append(f"{after}...", style="dim")
|
text.append(f"{after}...", style="dim")
|
||||||
|
|
||||||
if i < min(len(hits), 5) - 1:
|
if i < min(len(matches), 5) - 1:
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
|
|
||||||
if len(hits) > 5:
|
if len(matches) > 5:
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
text.append(f" ... +{len(hits) - 5} more matches", style="dim italic")
|
text.append(f" ... +{len(matches) - 5} more matches", style="dim italic")
|
||||||
|
|
||||||
elif "content" in result:
|
elif "content" in result:
|
||||||
page = result.get("page", 1)
|
showing = result.get("showing_lines", "")
|
||||||
total_lines = result.get("total_lines", 0)
|
|
||||||
has_more = result.get("has_more", False)
|
has_more = result.get("has_more", False)
|
||||||
content = result.get("content", "")
|
content = result.get("content", "")
|
||||||
|
|
||||||
text.append(f" [page {page}, {total_lines} lines]", style="dim")
|
text.append(f" [{showing}]", style="dim")
|
||||||
|
|
||||||
if content and isinstance(content, str):
|
if content and isinstance(content, str):
|
||||||
lines = content.split("\n")[:15]
|
lines = content.split("\n")[:15]
|
||||||
@@ -199,6 +195,80 @@ class ViewRequestRenderer(BaseToolRenderer):
|
|||||||
return Static(text, classes=css_classes)
|
return Static(text, classes=css_classes)
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class SendRequestRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "send_request"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
|
||||||
|
args = tool_data.get("args", {})
|
||||||
|
result = tool_data.get("result")
|
||||||
|
status = tool_data.get("status", "running")
|
||||||
|
|
||||||
|
method = args.get("method", "GET")
|
||||||
|
url = args.get("url", "")
|
||||||
|
req_headers = args.get("headers")
|
||||||
|
req_body = args.get("body", "")
|
||||||
|
|
||||||
|
text = Text()
|
||||||
|
text.append(PROXY_ICON, style="dim")
|
||||||
|
text.append(" sending request", style="#06b6d4")
|
||||||
|
|
||||||
|
text.append("\n")
|
||||||
|
text.append(" >> ", style="#3b82f6")
|
||||||
|
text.append(method, style="#a78bfa")
|
||||||
|
text.append(f" {_truncate(url, 180)}", style="dim")
|
||||||
|
|
||||||
|
if req_headers and isinstance(req_headers, dict):
|
||||||
|
for k, v in list(req_headers.items())[:5]:
|
||||||
|
text.append("\n")
|
||||||
|
text.append(" >> ", style="#3b82f6")
|
||||||
|
text.append(f"{k}: ", style="dim")
|
||||||
|
text.append(_sanitize(str(v), 150), style="dim")
|
||||||
|
|
||||||
|
if req_body and isinstance(req_body, str):
|
||||||
|
text.append("\n")
|
||||||
|
text.append(" >> ", style="#3b82f6")
|
||||||
|
body_lines = req_body.split("\n")[:4]
|
||||||
|
for i, line in enumerate(body_lines):
|
||||||
|
if i > 0:
|
||||||
|
text.append("\n")
|
||||||
|
text.append(" ", style="dim")
|
||||||
|
text.append(_truncate(line, MAX_LINE_LENGTH), style="dim")
|
||||||
|
if len(req_body.split("\n")) > 4:
|
||||||
|
text.append(" ...", style="dim italic")
|
||||||
|
|
||||||
|
if status == "completed" and isinstance(result, dict):
|
||||||
|
if "error" in result:
|
||||||
|
text.append(f"\n error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
||||||
|
else:
|
||||||
|
code = result.get("status_code")
|
||||||
|
time_ms = result.get("response_time_ms")
|
||||||
|
|
||||||
|
text.append("\n")
|
||||||
|
text.append(" << ", style="#22c55e")
|
||||||
|
if code:
|
||||||
|
text.append(f"{code}", style=_status_style(code))
|
||||||
|
if time_ms:
|
||||||
|
text.append(f" ({time_ms}ms)", style="dim")
|
||||||
|
|
||||||
|
body = result.get("body", "")
|
||||||
|
if body and isinstance(body, str):
|
||||||
|
lines = body.split("\n")[:6]
|
||||||
|
for line in lines:
|
||||||
|
text.append("\n")
|
||||||
|
text.append(" << ", style="#22c55e")
|
||||||
|
text.append(_truncate(line, MAX_LINE_LENGTH - 5), style="dim")
|
||||||
|
|
||||||
|
if len(body.split("\n")) > 6:
|
||||||
|
text.append("\n")
|
||||||
|
text.append(" ...", style="dim italic")
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes(status)
|
||||||
|
return Static(text, classes=css_classes)
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
@register_tool_renderer
|
||||||
class RepeatRequestRenderer(BaseToolRenderer):
|
class RepeatRequestRenderer(BaseToolRenderer):
|
||||||
tool_name: ClassVar[str] = "repeat_request"
|
tool_name: ClassVar[str] = "repeat_request"
|
||||||
@@ -262,26 +332,30 @@ class RepeatRequestRenderer(BaseToolRenderer):
|
|||||||
text.append(f"\n {_truncate(modifications, 200)}", style="dim italic")
|
text.append(f"\n {_truncate(modifications, 200)}", style="dim italic")
|
||||||
|
|
||||||
if status == "completed" and isinstance(result, dict):
|
if status == "completed" and isinstance(result, dict):
|
||||||
if not result.get("success", True) and result.get("error"):
|
if "error" in result:
|
||||||
text.append(f"\n error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
text.append(f"\n error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
||||||
else:
|
else:
|
||||||
elapsed_ms = result.get("elapsed_ms")
|
req = result.get("request", {})
|
||||||
response = result.get("response") or {}
|
method = req.get("method", "")
|
||||||
code = response.get("status_code") if isinstance(response, dict) else None
|
url = req.get("url", "")
|
||||||
body = response.get("body", "") if isinstance(response, dict) else ""
|
code = result.get("status_code")
|
||||||
body_truncated = (
|
time_ms = result.get("response_time_ms")
|
||||||
bool(response.get("body_truncated")) if isinstance(response, dict) else False
|
|
||||||
)
|
text.append("\n")
|
||||||
|
text.append(" >> ", style="#3b82f6")
|
||||||
|
if method:
|
||||||
|
text.append(f"{method} ", style="#a78bfa")
|
||||||
|
if url:
|
||||||
|
text.append(_truncate(url, 180), style="dim")
|
||||||
|
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
text.append(" << ", style="#22c55e")
|
text.append(" << ", style="#22c55e")
|
||||||
if code:
|
if code:
|
||||||
text.append(f"{code}", style=_status_style(code))
|
text.append(f"{code}", style=_status_style(code))
|
||||||
else:
|
if time_ms:
|
||||||
text.append("(no response)", style="dim")
|
text.append(f" ({time_ms}ms)", style="dim")
|
||||||
if elapsed_ms:
|
|
||||||
text.append(f" ({elapsed_ms}ms)", style="dim")
|
|
||||||
|
|
||||||
|
body = result.get("body", "")
|
||||||
if body and isinstance(body, str):
|
if body and isinstance(body, str):
|
||||||
lines = body.split("\n")[:5]
|
lines = body.split("\n")[:5]
|
||||||
for line in lines:
|
for line in lines:
|
||||||
@@ -289,7 +363,7 @@ class RepeatRequestRenderer(BaseToolRenderer):
|
|||||||
text.append(" << ", style="#22c55e")
|
text.append(" << ", style="#22c55e")
|
||||||
text.append(_truncate(line, MAX_LINE_LENGTH - 5), style="dim")
|
text.append(_truncate(line, MAX_LINE_LENGTH - 5), style="dim")
|
||||||
|
|
||||||
if body_truncated or len(body.split("\n")) > 5:
|
if len(body.split("\n")) > 5:
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
text.append(" ...", style="dim italic")
|
text.append(" ...", style="dim italic")
|
||||||
|
|
||||||
@@ -297,155 +371,6 @@ class RepeatRequestRenderer(BaseToolRenderer):
|
|||||||
return Static(text, classes=css_classes)
|
return Static(text, classes=css_classes)
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
|
||||||
class ListSitemapRenderer(BaseToolRenderer):
|
|
||||||
tool_name: ClassVar[str] = "list_sitemap"
|
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
|
|
||||||
args = tool_data.get("args", {})
|
|
||||||
result = tool_data.get("result")
|
|
||||||
status = tool_data.get("status", "running")
|
|
||||||
|
|
||||||
parent_id = args.get("parent_id")
|
|
||||||
scope_id = args.get("scope_id")
|
|
||||||
depth = args.get("depth")
|
|
||||||
|
|
||||||
text = Text()
|
|
||||||
text.append(PROXY_ICON, style="dim")
|
|
||||||
text.append(" listing sitemap", style="#06b6d4")
|
|
||||||
|
|
||||||
if parent_id:
|
|
||||||
text.append(f" under #{_truncate(str(parent_id), 20)}", style="dim")
|
|
||||||
|
|
||||||
meta_parts = []
|
|
||||||
if scope_id and isinstance(scope_id, str):
|
|
||||||
meta_parts.append(f"scope:{scope_id[:8]}")
|
|
||||||
if depth and depth != "DIRECT":
|
|
||||||
meta_parts.append(depth.lower())
|
|
||||||
if meta_parts:
|
|
||||||
text.append(f" ({', '.join(meta_parts)})", style="dim")
|
|
||||||
|
|
||||||
if status == "completed" and isinstance(result, dict):
|
|
||||||
if "error" in result:
|
|
||||||
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
|
||||||
else:
|
|
||||||
total = result.get("total_count", 0)
|
|
||||||
entries = result.get("entries", [])
|
|
||||||
|
|
||||||
text.append(f" [{total} entries]", style="dim")
|
|
||||||
|
|
||||||
if entries and isinstance(entries, list):
|
|
||||||
text.append("\n")
|
|
||||||
for i, entry in enumerate(entries[:MAX_REQUESTS_DISPLAY]):
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
kind = entry.get("kind") or "?"
|
|
||||||
label = entry.get("label") or "?"
|
|
||||||
has_children = entry.get("has_descendants", False)
|
|
||||||
req = entry.get("request") or {}
|
|
||||||
|
|
||||||
kind_style = {
|
|
||||||
"DOMAIN": "#f59e0b",
|
|
||||||
"DIRECTORY": "#3b82f6",
|
|
||||||
"REQUEST": "#22c55e",
|
|
||||||
}.get(kind, "dim")
|
|
||||||
|
|
||||||
text.append(" ")
|
|
||||||
kind_abbr = kind[:3] if isinstance(kind, str) else "?"
|
|
||||||
text.append(f"{kind_abbr:3}", style=kind_style)
|
|
||||||
text.append(f" {_truncate(label, 150)}", style="dim")
|
|
||||||
|
|
||||||
if req:
|
|
||||||
method = req.get("method", "")
|
|
||||||
code = req.get("status_code")
|
|
||||||
if method:
|
|
||||||
text.append(f" {method}", style="#a78bfa")
|
|
||||||
if code:
|
|
||||||
text.append(f" {code}", style=_status_style(code))
|
|
||||||
|
|
||||||
if has_children:
|
|
||||||
text.append(" +", style="dim italic")
|
|
||||||
|
|
||||||
if i < min(len(entries), MAX_REQUESTS_DISPLAY) - 1:
|
|
||||||
text.append("\n")
|
|
||||||
|
|
||||||
if len(entries) > MAX_REQUESTS_DISPLAY:
|
|
||||||
text.append("\n")
|
|
||||||
text.append(
|
|
||||||
f" ... +{len(entries) - MAX_REQUESTS_DISPLAY} more", style="dim italic"
|
|
||||||
)
|
|
||||||
|
|
||||||
css_classes = cls.get_css_classes(status)
|
|
||||||
return Static(text, classes=css_classes)
|
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
|
||||||
class ViewSitemapEntryRenderer(BaseToolRenderer):
|
|
||||||
tool_name: ClassVar[str] = "view_sitemap_entry"
|
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912
|
|
||||||
args = tool_data.get("args", {})
|
|
||||||
result = tool_data.get("result")
|
|
||||||
status = tool_data.get("status", "running")
|
|
||||||
|
|
||||||
entry_id = args.get("entry_id", "")
|
|
||||||
|
|
||||||
text = Text()
|
|
||||||
text.append(PROXY_ICON, style="dim")
|
|
||||||
text.append(" viewing sitemap", style="#06b6d4")
|
|
||||||
|
|
||||||
if entry_id:
|
|
||||||
text.append(f" #{_truncate(str(entry_id), 20)}", style="dim")
|
|
||||||
|
|
||||||
if status == "completed" and isinstance(result, dict):
|
|
||||||
if "error" in result:
|
|
||||||
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
|
||||||
elif "entry" in result:
|
|
||||||
entry = result.get("entry") or {}
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
entry = {}
|
|
||||||
kind = entry.get("kind", "")
|
|
||||||
label = entry.get("label", "")
|
|
||||||
related = entry.get("related_requests") or {}
|
|
||||||
related_reqs = related.get("requests", []) if isinstance(related, dict) else []
|
|
||||||
total_related = related.get("total_count", 0) if isinstance(related, dict) else 0
|
|
||||||
|
|
||||||
if kind and label:
|
|
||||||
text.append(f" {kind}: {_truncate(label, 120)}", style="dim")
|
|
||||||
|
|
||||||
if total_related:
|
|
||||||
text.append(f" [{total_related} requests]", style="dim")
|
|
||||||
|
|
||||||
if related_reqs and isinstance(related_reqs, list):
|
|
||||||
text.append("\n")
|
|
||||||
for i, req in enumerate(related_reqs[:10]):
|
|
||||||
if not isinstance(req, dict):
|
|
||||||
continue
|
|
||||||
method = req.get("method", "?")
|
|
||||||
path = req.get("path", "/")
|
|
||||||
code = req.get("status_code")
|
|
||||||
|
|
||||||
text.append(" ")
|
|
||||||
text.append(f"{method:6}", style="#a78bfa")
|
|
||||||
text.append(f" {_truncate(path, 180)}", style="dim")
|
|
||||||
if code:
|
|
||||||
text.append(f" {code}", style=_status_style(code))
|
|
||||||
|
|
||||||
if i < min(len(related_reqs), 10) - 1:
|
|
||||||
text.append("\n")
|
|
||||||
|
|
||||||
if len(related_reqs) > 10:
|
|
||||||
text.append("\n")
|
|
||||||
text.append(f" ... +{len(related_reqs) - 10} more", style="dim italic")
|
|
||||||
|
|
||||||
css_classes = cls.get_css_classes(status)
|
|
||||||
return Static(text, classes=css_classes)
|
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
@register_tool_renderer
|
||||||
class ScopeRulesRenderer(BaseToolRenderer):
|
class ScopeRulesRenderer(BaseToolRenderer):
|
||||||
tool_name: ClassVar[str] = "scope_rules"
|
tool_name: ClassVar[str] = "scope_rules"
|
||||||
@@ -534,3 +459,152 @@ class ScopeRulesRenderer(BaseToolRenderer):
|
|||||||
|
|
||||||
css_classes = cls.get_css_classes(status)
|
css_classes = cls.get_css_classes(status)
|
||||||
return Static(text, classes=css_classes)
|
return Static(text, classes=css_classes)
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class ListSitemapRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "list_sitemap"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
|
||||||
|
args = tool_data.get("args", {})
|
||||||
|
result = tool_data.get("result")
|
||||||
|
status = tool_data.get("status", "running")
|
||||||
|
|
||||||
|
parent_id = args.get("parent_id")
|
||||||
|
scope_id = args.get("scope_id")
|
||||||
|
depth = args.get("depth")
|
||||||
|
|
||||||
|
text = Text()
|
||||||
|
text.append(PROXY_ICON, style="dim")
|
||||||
|
text.append(" listing sitemap", style="#06b6d4")
|
||||||
|
|
||||||
|
if parent_id:
|
||||||
|
text.append(f" under #{_truncate(str(parent_id), 20)}", style="dim")
|
||||||
|
|
||||||
|
meta_parts = []
|
||||||
|
if scope_id and isinstance(scope_id, str):
|
||||||
|
meta_parts.append(f"scope:{scope_id[:8]}")
|
||||||
|
if depth and depth != "DIRECT":
|
||||||
|
meta_parts.append(depth.lower())
|
||||||
|
if meta_parts:
|
||||||
|
text.append(f" ({', '.join(meta_parts)})", style="dim")
|
||||||
|
|
||||||
|
if status == "completed" and isinstance(result, dict):
|
||||||
|
if "error" in result:
|
||||||
|
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
||||||
|
else:
|
||||||
|
total = result.get("total_count", 0)
|
||||||
|
entries = result.get("entries", [])
|
||||||
|
|
||||||
|
text.append(f" [{total} entries]", style="dim")
|
||||||
|
|
||||||
|
if entries and isinstance(entries, list):
|
||||||
|
text.append("\n")
|
||||||
|
for i, entry in enumerate(entries[:MAX_REQUESTS_DISPLAY]):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
kind = entry.get("kind") or "?"
|
||||||
|
label = entry.get("label") or "?"
|
||||||
|
has_children = entry.get("hasDescendants", False)
|
||||||
|
req = entry.get("request") or {}
|
||||||
|
|
||||||
|
kind_style = {
|
||||||
|
"DOMAIN": "#f59e0b",
|
||||||
|
"DIRECTORY": "#3b82f6",
|
||||||
|
"REQUEST": "#22c55e",
|
||||||
|
}.get(kind, "dim")
|
||||||
|
|
||||||
|
text.append(" ")
|
||||||
|
kind_abbr = kind[:3] if isinstance(kind, str) else "?"
|
||||||
|
text.append(f"{kind_abbr:3}", style=kind_style)
|
||||||
|
text.append(f" {_truncate(label, 150)}", style="dim")
|
||||||
|
|
||||||
|
if req:
|
||||||
|
method = req.get("method", "")
|
||||||
|
code = req.get("status")
|
||||||
|
if method:
|
||||||
|
text.append(f" {method}", style="#a78bfa")
|
||||||
|
if code:
|
||||||
|
text.append(f" {code}", style=_status_style(code))
|
||||||
|
|
||||||
|
if has_children:
|
||||||
|
text.append(" +", style="dim italic")
|
||||||
|
|
||||||
|
if i < min(len(entries), MAX_REQUESTS_DISPLAY) - 1:
|
||||||
|
text.append("\n")
|
||||||
|
|
||||||
|
if len(entries) > MAX_REQUESTS_DISPLAY:
|
||||||
|
text.append("\n")
|
||||||
|
text.append(
|
||||||
|
f" ... +{len(entries) - MAX_REQUESTS_DISPLAY} more", style="dim italic"
|
||||||
|
)
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes(status)
|
||||||
|
return Static(text, classes=css_classes)
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class ViewSitemapEntryRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "view_sitemap_entry"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912
|
||||||
|
args = tool_data.get("args", {})
|
||||||
|
result = tool_data.get("result")
|
||||||
|
status = tool_data.get("status", "running")
|
||||||
|
|
||||||
|
entry_id = args.get("entry_id", "")
|
||||||
|
|
||||||
|
text = Text()
|
||||||
|
text.append(PROXY_ICON, style="dim")
|
||||||
|
text.append(" viewing sitemap", style="#06b6d4")
|
||||||
|
|
||||||
|
if entry_id:
|
||||||
|
text.append(f" #{_truncate(str(entry_id), 20)}", style="dim")
|
||||||
|
|
||||||
|
if status == "completed" and isinstance(result, dict):
|
||||||
|
if "error" in result:
|
||||||
|
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
||||||
|
elif "entry" in result:
|
||||||
|
entry = result.get("entry") or {}
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
entry = {}
|
||||||
|
kind = entry.get("kind", "")
|
||||||
|
label = entry.get("label", "")
|
||||||
|
related = entry.get("related_requests") or {}
|
||||||
|
related_reqs = related.get("requests", []) if isinstance(related, dict) else []
|
||||||
|
total_related = related.get("total_count", 0) if isinstance(related, dict) else 0
|
||||||
|
|
||||||
|
if kind and label:
|
||||||
|
text.append(f" {kind}: {_truncate(label, 120)}", style="dim")
|
||||||
|
|
||||||
|
if total_related:
|
||||||
|
text.append(f" [{total_related} requests]", style="dim")
|
||||||
|
|
||||||
|
if related_reqs and isinstance(related_reqs, list):
|
||||||
|
text.append("\n")
|
||||||
|
for i, req in enumerate(related_reqs[:10]):
|
||||||
|
if not isinstance(req, dict):
|
||||||
|
continue
|
||||||
|
method = req.get("method", "?")
|
||||||
|
path = req.get("path", "/")
|
||||||
|
code = req.get("status")
|
||||||
|
|
||||||
|
text.append(" ")
|
||||||
|
text.append(f"{method:6}", style="#a78bfa")
|
||||||
|
text.append(f" {_truncate(path, 180)}", style="dim")
|
||||||
|
if code:
|
||||||
|
text.append(f" {code}", style=_status_style(code))
|
||||||
|
|
||||||
|
if i < min(len(related_reqs), 10) - 1:
|
||||||
|
text.append("\n")
|
||||||
|
|
||||||
|
if len(related_reqs) > 10:
|
||||||
|
text.append("\n")
|
||||||
|
text.append(f" ... +{len(related_reqs) - 10} more", style="dim italic")
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes(status)
|
||||||
|
return Static(text, classes=css_classes)
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import re
|
||||||
|
from functools import cache
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from pygments.lexers import PythonLexer
|
||||||
|
from pygments.styles import get_style_by_name
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from .base_renderer import BaseToolRenderer
|
||||||
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
MAX_OUTPUT_LINES = 50
|
||||||
|
MAX_LINE_LENGTH = 200
|
||||||
|
|
||||||
|
ANSI_PATTERN = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)")
|
||||||
|
|
||||||
|
STRIP_PATTERNS = [
|
||||||
|
r"\.\.\. \[(stdout|stderr|result|output|error) truncated at \d+k? chars\]",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _get_style_colors() -> dict[Any, str]:
|
||||||
|
style = get_style_by_name("native")
|
||||||
|
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _get_lexer() -> PythonLexer:
|
||||||
|
return PythonLexer()
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _get_token_color(token_type: Any) -> str | None:
|
||||||
|
colors = _get_style_colors()
|
||||||
|
while token_type:
|
||||||
|
if token_type in colors:
|
||||||
|
return colors[token_type]
|
||||||
|
token_type = token_type.parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class PythonRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "python_action"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "python-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _highlight_python(cls, code: str) -> Text:
|
||||||
|
text = Text()
|
||||||
|
for token_type, token_value in _get_lexer().get_tokens(code):
|
||||||
|
if token_value:
|
||||||
|
text.append(token_value, style=_get_token_color(token_type))
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _clean_output(cls, output: str) -> str:
|
||||||
|
cleaned = output
|
||||||
|
for pattern in STRIP_PATTERNS:
|
||||||
|
cleaned = re.sub(pattern, "", cleaned)
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _strip_ansi(cls, text: str) -> str:
|
||||||
|
return ANSI_PATTERN.sub("", text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _truncate_line(cls, line: str) -> str:
|
||||||
|
clean_line = cls._strip_ansi(line)
|
||||||
|
if len(clean_line) > MAX_LINE_LENGTH:
|
||||||
|
return clean_line[: MAX_LINE_LENGTH - 3] + "..."
|
||||||
|
return clean_line
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_output(cls, output: str) -> Text:
|
||||||
|
text = Text()
|
||||||
|
lines = output.splitlines()
|
||||||
|
total_lines = len(lines)
|
||||||
|
|
||||||
|
head_count = MAX_OUTPUT_LINES // 2
|
||||||
|
tail_count = MAX_OUTPUT_LINES - head_count - 1
|
||||||
|
|
||||||
|
if total_lines <= MAX_OUTPUT_LINES:
|
||||||
|
display_lines = lines
|
||||||
|
truncated = False
|
||||||
|
hidden_count = 0
|
||||||
|
else:
|
||||||
|
display_lines = lines[:head_count]
|
||||||
|
truncated = True
|
||||||
|
hidden_count = total_lines - head_count - tail_count
|
||||||
|
|
||||||
|
for i, line in enumerate(display_lines):
|
||||||
|
truncated_line = cls._truncate_line(line)
|
||||||
|
text.append(" ")
|
||||||
|
text.append(truncated_line, style="dim")
|
||||||
|
if i < len(display_lines) - 1 or truncated:
|
||||||
|
text.append("\n")
|
||||||
|
|
||||||
|
if truncated:
|
||||||
|
text.append(f" ... {hidden_count} lines truncated ...", style="dim italic")
|
||||||
|
text.append("\n")
|
||||||
|
tail_lines = lines[-tail_count:]
|
||||||
|
for i, line in enumerate(tail_lines):
|
||||||
|
truncated_line = cls._truncate_line(line)
|
||||||
|
text.append(" ")
|
||||||
|
text.append(truncated_line, style="dim")
|
||||||
|
if i < len(tail_lines) - 1:
|
||||||
|
text.append("\n")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _append_output(cls, text: Text, result: dict[str, Any] | str) -> None:
|
||||||
|
if isinstance(result, str):
|
||||||
|
if result.strip():
|
||||||
|
text.append("\n")
|
||||||
|
text.append_text(cls._format_output(result))
|
||||||
|
return
|
||||||
|
|
||||||
|
stdout = result.get("stdout", "")
|
||||||
|
stdout = cls._clean_output(stdout) if stdout else ""
|
||||||
|
|
||||||
|
if stdout:
|
||||||
|
text.append("\n")
|
||||||
|
formatted_output = cls._format_output(stdout)
|
||||||
|
text.append_text(formatted_output)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
args = tool_data.get("args", {})
|
||||||
|
status = tool_data.get("status", "unknown")
|
||||||
|
result = tool_data.get("result")
|
||||||
|
|
||||||
|
action = args.get("action", "")
|
||||||
|
code = args.get("code", "")
|
||||||
|
|
||||||
|
text = Text()
|
||||||
|
text.append("</> ", style="dim")
|
||||||
|
|
||||||
|
if code and action in ["new_session", "execute"]:
|
||||||
|
text.append_text(cls._highlight_python(code))
|
||||||
|
elif action == "close":
|
||||||
|
text.append("Closing session...", style="dim")
|
||||||
|
elif action == "list_sessions":
|
||||||
|
text.append("Listing sessions...", style="dim")
|
||||||
|
else:
|
||||||
|
text.append("Running...", style="dim")
|
||||||
|
|
||||||
|
if result and isinstance(result, dict | str):
|
||||||
|
cls._append_output(text, result)
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes(status)
|
||||||
|
return Static(text, classes=css_classes)
|
||||||
+8
@@ -20,6 +20,14 @@ class ToolTUIRegistry:
|
|||||||
def get_renderer(cls, tool_name: str) -> type[BaseToolRenderer] | None:
|
def get_renderer(cls, tool_name: str) -> type[BaseToolRenderer] | None:
|
||||||
return cls._renderers.get(tool_name)
|
return cls._renderers.get(tool_name)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_tools(cls) -> list[str]:
|
||||||
|
return list(cls._renderers.keys())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def has_renderer(cls, tool_name: str) -> bool:
|
||||||
|
return tool_name in cls._renderers
|
||||||
|
|
||||||
|
|
||||||
def register_tool_renderer(renderer_class: type[BaseToolRenderer]) -> type[BaseToolRenderer]:
|
def register_tool_renderer(renderer_class: type[BaseToolRenderer]) -> type[BaseToolRenderer]:
|
||||||
ToolTUIRegistry.register(renderer_class)
|
ToolTUIRegistry.register(renderer_class)
|
||||||
+15
-18
@@ -6,22 +6,15 @@ from pygments.styles import get_style_by_name
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from strix.tools.reporting.reporting_actions import (
|
||||||
|
parse_code_locations_xml,
|
||||||
|
parse_cvss_xml,
|
||||||
|
)
|
||||||
|
|
||||||
from .base_renderer import BaseToolRenderer
|
from .base_renderer import BaseToolRenderer
|
||||||
from .registry import register_tool_renderer
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
def _coerce_dict(value: Any) -> dict[str, Any]:
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return value
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_list_of_dicts(value: Any) -> list[dict[str, Any]]:
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [item for item in value if isinstance(item, dict)]
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@cache
|
@cache
|
||||||
def _get_style_colors() -> dict[Any, str]:
|
def _get_style_colors() -> dict[Any, str]:
|
||||||
style = get_style_by_name("native")
|
style = get_style_by_name("native")
|
||||||
@@ -99,8 +92,8 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
|||||||
poc_script_code = args.get("poc_script_code", "")
|
poc_script_code = args.get("poc_script_code", "")
|
||||||
remediation_steps = args.get("remediation_steps", "")
|
remediation_steps = args.get("remediation_steps", "")
|
||||||
|
|
||||||
cvss_breakdown = _coerce_dict(args.get("cvss_breakdown"))
|
cvss_breakdown_xml = args.get("cvss_breakdown", "")
|
||||||
code_locations = _coerce_list_of_dicts(args.get("code_locations"))
|
code_locations_xml = args.get("code_locations", "")
|
||||||
|
|
||||||
endpoint = args.get("endpoint", "")
|
endpoint = args.get("endpoint", "")
|
||||||
method = args.get("method", "")
|
method = args.get("method", "")
|
||||||
@@ -159,7 +152,8 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
|||||||
text.append("CWE: ", style=FIELD_STYLE)
|
text.append("CWE: ", style=FIELD_STYLE)
|
||||||
text.append(cwe)
|
text.append(cwe)
|
||||||
|
|
||||||
if cvss_breakdown:
|
parsed_cvss = parse_cvss_xml(cvss_breakdown_xml) if cvss_breakdown_xml else None
|
||||||
|
if parsed_cvss:
|
||||||
text.append("\n\n")
|
text.append("\n\n")
|
||||||
cvss_parts = []
|
cvss_parts = []
|
||||||
for key, prefix in [
|
for key, prefix in [
|
||||||
@@ -172,7 +166,7 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
|||||||
("integrity", "I"),
|
("integrity", "I"),
|
||||||
("availability", "A"),
|
("availability", "A"),
|
||||||
]:
|
]:
|
||||||
val = cvss_breakdown.get(key)
|
val = parsed_cvss.get(key)
|
||||||
if val:
|
if val:
|
||||||
cvss_parts.append(f"{prefix}:{val}")
|
cvss_parts.append(f"{prefix}:{val}")
|
||||||
text.append("CVSS Vector: ", style=FIELD_STYLE)
|
text.append("CVSS Vector: ", style=FIELD_STYLE)
|
||||||
@@ -196,10 +190,13 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
|||||||
text.append("\n")
|
text.append("\n")
|
||||||
text.append(technical_analysis)
|
text.append(technical_analysis)
|
||||||
|
|
||||||
if code_locations:
|
parsed_locations = (
|
||||||
|
parse_code_locations_xml(code_locations_xml) if code_locations_xml else None
|
||||||
|
)
|
||||||
|
if parsed_locations:
|
||||||
text.append("\n\n")
|
text.append("\n\n")
|
||||||
text.append("Code Locations", style=FIELD_STYLE)
|
text.append("Code Locations", style=FIELD_STYLE)
|
||||||
for i, loc in enumerate(code_locations):
|
for i, loc in enumerate(parsed_locations):
|
||||||
text.append("\n\n")
|
text.append("\n\n")
|
||||||
text.append(f" Location {i + 1}: ", style=DIM_STYLE)
|
text.append(f" Location {i + 1}: ", style=DIM_STYLE)
|
||||||
text.append(loc.get("file", "unknown"), style=FILE_STYLE)
|
text.append(loc.get("file", "unknown"), style=FILE_STYLE)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from .base_renderer import BaseToolRenderer
|
||||||
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class ScanStartInfoRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "scan_start_info"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "scan-info-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
args = tool_data.get("args", {})
|
||||||
|
status = tool_data.get("status", "unknown")
|
||||||
|
targets = args.get("targets", [])
|
||||||
|
|
||||||
|
text = Text()
|
||||||
|
text.append("◈ ", style="#22c55e")
|
||||||
|
text.append("Starting penetration test")
|
||||||
|
|
||||||
|
if len(targets) == 1:
|
||||||
|
text.append(" on ")
|
||||||
|
text.append(cls._get_target_display(targets[0]))
|
||||||
|
elif len(targets) > 1:
|
||||||
|
text.append(f" on {len(targets)} targets")
|
||||||
|
for target_info in targets:
|
||||||
|
text.append("\n • ")
|
||||||
|
text.append(cls._get_target_display(target_info))
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes(status)
|
||||||
|
return Static(text, classes=css_classes)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_target_display(cls, target_info: dict[str, Any]) -> str:
|
||||||
|
original = target_info.get("original")
|
||||||
|
if original:
|
||||||
|
return str(original)
|
||||||
|
return "unknown target"
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class SubagentStartInfoRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "subagent_start_info"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "subagent-info-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
args = tool_data.get("args", {})
|
||||||
|
status = tool_data.get("status", "unknown")
|
||||||
|
|
||||||
|
name = str(args.get("name", "Unknown Agent"))
|
||||||
|
task = str(args.get("task", ""))
|
||||||
|
|
||||||
|
text = Text()
|
||||||
|
text.append("◈ ", style="#a78bfa")
|
||||||
|
text.append("subagent ", style="dim")
|
||||||
|
text.append(name, style="bold #a78bfa")
|
||||||
|
|
||||||
|
if task:
|
||||||
|
text.append("\n ")
|
||||||
|
text.append(task, style="dim")
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes(status)
|
||||||
|
return Static(text, classes=css_classes)
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import re
|
||||||
|
from functools import cache
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from pygments.lexers import get_lexer_by_name
|
||||||
|
from pygments.styles import get_style_by_name
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from .base_renderer import BaseToolRenderer
|
||||||
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
MAX_OUTPUT_LINES = 50
|
||||||
|
MAX_LINE_LENGTH = 200
|
||||||
|
|
||||||
|
STRIP_PATTERNS = [
|
||||||
|
(
|
||||||
|
r"\n?\[Command still running after [\d.]+s - showing output so far\.?"
|
||||||
|
r"\s*(?:Use C-c to interrupt if needed\.)?\]"
|
||||||
|
),
|
||||||
|
r"^\[Below is the output of the previous command\.\]\n?",
|
||||||
|
r"^No command is currently running\. Cannot send input\.$",
|
||||||
|
(
|
||||||
|
r"^A command is already running\. Use is_input=true to send input to it, "
|
||||||
|
r"or interrupt it first \(e\.g\., with C-c\)\.$"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _get_style_colors() -> dict[Any, str]:
|
||||||
|
style = get_style_by_name("native")
|
||||||
|
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class TerminalRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "terminal_execute"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
|
||||||
|
|
||||||
|
CONTROL_SEQUENCES: ClassVar[set[str]] = {
|
||||||
|
"C-c",
|
||||||
|
"C-d",
|
||||||
|
"C-z",
|
||||||
|
"C-a",
|
||||||
|
"C-e",
|
||||||
|
"C-k",
|
||||||
|
"C-l",
|
||||||
|
"C-u",
|
||||||
|
"C-w",
|
||||||
|
"C-r",
|
||||||
|
"C-s",
|
||||||
|
"C-t",
|
||||||
|
"C-y",
|
||||||
|
"^c",
|
||||||
|
"^d",
|
||||||
|
"^z",
|
||||||
|
"^a",
|
||||||
|
"^e",
|
||||||
|
"^k",
|
||||||
|
"^l",
|
||||||
|
"^u",
|
||||||
|
"^w",
|
||||||
|
"^r",
|
||||||
|
"^s",
|
||||||
|
"^t",
|
||||||
|
"^y",
|
||||||
|
}
|
||||||
|
SPECIAL_KEYS: ClassVar[set[str]] = {
|
||||||
|
"Enter",
|
||||||
|
"Escape",
|
||||||
|
"Space",
|
||||||
|
"Tab",
|
||||||
|
"BTab",
|
||||||
|
"BSpace",
|
||||||
|
"DC",
|
||||||
|
"IC",
|
||||||
|
"Up",
|
||||||
|
"Down",
|
||||||
|
"Left",
|
||||||
|
"Right",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
"PgUp",
|
||||||
|
"PgDn",
|
||||||
|
"PPage",
|
||||||
|
"NPage",
|
||||||
|
"F1",
|
||||||
|
"F2",
|
||||||
|
"F3",
|
||||||
|
"F4",
|
||||||
|
"F5",
|
||||||
|
"F6",
|
||||||
|
"F7",
|
||||||
|
"F8",
|
||||||
|
"F9",
|
||||||
|
"F10",
|
||||||
|
"F11",
|
||||||
|
"F12",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_token_color(cls, token_type: Any) -> str | None:
|
||||||
|
colors = _get_style_colors()
|
||||||
|
while token_type:
|
||||||
|
if token_type in colors:
|
||||||
|
return colors[token_type]
|
||||||
|
token_type = token_type.parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _highlight_bash(cls, code: str) -> Text:
|
||||||
|
lexer = get_lexer_by_name("bash")
|
||||||
|
text = Text()
|
||||||
|
|
||||||
|
for token_type, token_value in lexer.get_tokens(code):
|
||||||
|
if not token_value:
|
||||||
|
continue
|
||||||
|
color = cls._get_token_color(token_type)
|
||||||
|
text.append(token_value, style=color)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
args = tool_data.get("args", {})
|
||||||
|
status = tool_data.get("status", "unknown")
|
||||||
|
result = tool_data.get("result")
|
||||||
|
|
||||||
|
command = args.get("command", "")
|
||||||
|
is_input = args.get("is_input", False)
|
||||||
|
|
||||||
|
content = cls._build_content(command, is_input, status, result)
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes(status)
|
||||||
|
return Static(content, classes=css_classes)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_content(
|
||||||
|
cls, command: str, is_input: bool, status: str, result: dict[str, Any] | str | None
|
||||||
|
) -> Text:
|
||||||
|
text = Text()
|
||||||
|
terminal_icon = ">_"
|
||||||
|
|
||||||
|
if not command.strip():
|
||||||
|
text.append(terminal_icon, style="dim")
|
||||||
|
text.append(" ")
|
||||||
|
text.append("getting logs...", style="dim")
|
||||||
|
if result:
|
||||||
|
cls._append_output(text, result, status, command)
|
||||||
|
return text
|
||||||
|
|
||||||
|
is_special = (
|
||||||
|
command in cls.CONTROL_SEQUENCES
|
||||||
|
or command in cls.SPECIAL_KEYS
|
||||||
|
or command.startswith(("M-", "S-", "C-S-", "C-M-", "S-M-"))
|
||||||
|
)
|
||||||
|
|
||||||
|
text.append(terminal_icon, style="dim")
|
||||||
|
text.append(" ")
|
||||||
|
|
||||||
|
if is_special:
|
||||||
|
text.append(command, style="#ef4444")
|
||||||
|
elif is_input:
|
||||||
|
text.append(">>>", style="#3b82f6")
|
||||||
|
text.append(" ")
|
||||||
|
text.append_text(cls._format_command(command))
|
||||||
|
else:
|
||||||
|
text.append("$", style="#22c55e")
|
||||||
|
text.append(" ")
|
||||||
|
text.append_text(cls._format_command(command))
|
||||||
|
|
||||||
|
if result:
|
||||||
|
cls._append_output(text, result, status, command)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _clean_output(cls, output: str, command: str = "") -> str:
|
||||||
|
cleaned = output
|
||||||
|
|
||||||
|
for pattern in STRIP_PATTERNS:
|
||||||
|
cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
if cleaned.strip():
|
||||||
|
lines = cleaned.splitlines()
|
||||||
|
filtered_lines: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
if not filtered_lines and not line.strip():
|
||||||
|
continue
|
||||||
|
if re.match(r"^\[STRIX_\d+\]\$\s*", line):
|
||||||
|
continue
|
||||||
|
if command and line.strip() == command.strip():
|
||||||
|
continue
|
||||||
|
if command and re.match(r"^[\$#>]\s*" + re.escape(command.strip()) + r"\s*$", line):
|
||||||
|
continue
|
||||||
|
filtered_lines.append(line)
|
||||||
|
|
||||||
|
while filtered_lines and re.match(r"^\[STRIX_\d+\]\$\s*", filtered_lines[-1]):
|
||||||
|
filtered_lines.pop()
|
||||||
|
|
||||||
|
cleaned = "\n".join(filtered_lines)
|
||||||
|
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _append_output(
|
||||||
|
cls, text: Text, result: dict[str, Any] | str, tool_status: str, command: str = ""
|
||||||
|
) -> None:
|
||||||
|
if isinstance(result, str):
|
||||||
|
if result.strip():
|
||||||
|
text.append("\n")
|
||||||
|
text.append_text(cls._format_output(result))
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_output = result.get("content", "")
|
||||||
|
output = cls._clean_output(raw_output, command)
|
||||||
|
error = result.get("error")
|
||||||
|
exit_code = result.get("exit_code")
|
||||||
|
result_status = result.get("status", "")
|
||||||
|
|
||||||
|
if error and not cls._is_status_message(error):
|
||||||
|
text.append("\n")
|
||||||
|
text.append(" error: ", style="bold #ef4444")
|
||||||
|
text.append(cls._truncate_line(error), style="#ef4444")
|
||||||
|
return
|
||||||
|
|
||||||
|
if result_status == "running" or tool_status == "running":
|
||||||
|
if output and output.strip():
|
||||||
|
text.append("\n")
|
||||||
|
formatted_output = cls._format_output(output)
|
||||||
|
text.append_text(formatted_output)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not output or not output.strip():
|
||||||
|
if exit_code is not None and exit_code != 0:
|
||||||
|
text.append("\n")
|
||||||
|
text.append(f" exit {exit_code}", style="dim #ef4444")
|
||||||
|
return
|
||||||
|
|
||||||
|
text.append("\n")
|
||||||
|
formatted_output = cls._format_output(output)
|
||||||
|
text.append_text(formatted_output)
|
||||||
|
|
||||||
|
if exit_code is not None and exit_code != 0:
|
||||||
|
text.append("\n")
|
||||||
|
text.append(f" exit {exit_code}", style="dim #ef4444")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_status_message(cls, message: str) -> bool:
|
||||||
|
status_patterns = [
|
||||||
|
r"No command is currently running",
|
||||||
|
r"A command is already running",
|
||||||
|
r"Cannot send input",
|
||||||
|
r"Use is_input=true",
|
||||||
|
r"Use C-c to interrupt",
|
||||||
|
r"showing output so far",
|
||||||
|
]
|
||||||
|
return any(re.search(pattern, message) for pattern in status_patterns)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_output(cls, output: str) -> Text:
|
||||||
|
text = Text()
|
||||||
|
lines = output.splitlines()
|
||||||
|
total_lines = len(lines)
|
||||||
|
|
||||||
|
head_count = MAX_OUTPUT_LINES // 2
|
||||||
|
tail_count = MAX_OUTPUT_LINES - head_count - 1
|
||||||
|
|
||||||
|
if total_lines <= MAX_OUTPUT_LINES:
|
||||||
|
display_lines = lines
|
||||||
|
truncated = False
|
||||||
|
hidden_count = 0
|
||||||
|
else:
|
||||||
|
display_lines = lines[:head_count]
|
||||||
|
truncated = True
|
||||||
|
hidden_count = total_lines - head_count - tail_count
|
||||||
|
|
||||||
|
for i, line in enumerate(display_lines):
|
||||||
|
truncated_line = cls._truncate_line(line)
|
||||||
|
text.append(" ")
|
||||||
|
text.append(truncated_line, style="dim")
|
||||||
|
if i < len(display_lines) - 1 or truncated:
|
||||||
|
text.append("\n")
|
||||||
|
|
||||||
|
if truncated:
|
||||||
|
text.append(f" ... {hidden_count} lines truncated ...", style="dim italic")
|
||||||
|
text.append("\n")
|
||||||
|
tail_lines = lines[-tail_count:]
|
||||||
|
for i, line in enumerate(tail_lines):
|
||||||
|
truncated_line = cls._truncate_line(line)
|
||||||
|
text.append(" ")
|
||||||
|
text.append(truncated_line, style="dim")
|
||||||
|
if i < len(tail_lines) - 1:
|
||||||
|
text.append("\n")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _truncate_line(cls, line: str) -> str:
|
||||||
|
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||||
|
if len(clean_line) > MAX_LINE_LENGTH:
|
||||||
|
return line[: MAX_LINE_LENGTH - 3] + "..."
|
||||||
|
return line
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_command(cls, command: str) -> Text:
|
||||||
|
return cls._highlight_bash(command)
|
||||||
+22
-1
@@ -1,7 +1,28 @@
|
|||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from .base_renderer import BaseToolRenderer
|
||||||
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
class UserMessageRenderer:
|
@register_tool_renderer
|
||||||
|
class UserMessageRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "user_message"
|
||||||
|
css_classes: ClassVar[list[str]] = ["chat-message", "user-message"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
content = tool_data.get("content", "")
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
return Static(Text(), classes=" ".join(cls.css_classes))
|
||||||
|
|
||||||
|
styled_text = cls._format_user_message(content)
|
||||||
|
|
||||||
|
return Static(styled_text, classes=" ".join(cls.css_classes))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def render_simple(cls, content: str) -> Text:
|
def render_simple(cls, content: str) -> Text:
|
||||||
if not content:
|
if not content:
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
"""Textual TUI interface."""
|
|
||||||
|
|
||||||
from strix.interface.tui.app import StrixTUIApp, run_tui
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["StrixTUIApp", "run_tui"]
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"""Historical SDK session loading for the TUI."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import sqlite3
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from strix.core.paths import runtime_state_dir
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def load_session_history(run_dir: Path, agent_ids: Any) -> list[tuple[str, dict[str, Any], str]]:
|
|
||||||
agents_db = runtime_state_dir(run_dir) / "agents.db"
|
|
||||||
session_ids = [aid for aid in agent_ids if isinstance(aid, str)]
|
|
||||||
if not agents_db.exists() or not session_ids:
|
|
||||||
return []
|
|
||||||
session_id_set = set(session_ids)
|
|
||||||
try:
|
|
||||||
with sqlite3.connect(agents_db) as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"select id, session_id, message_data, created_at from agent_messages order by id"
|
|
||||||
).fetchall()
|
|
||||||
except sqlite3.Error:
|
|
||||||
logger.exception("Failed to hydrate TUI history from %s", agents_db)
|
|
||||||
return []
|
|
||||||
|
|
||||||
items: list[tuple[str, dict[str, Any], str]] = []
|
|
||||||
for row_id, agent_id, message_data, created_at in rows:
|
|
||||||
if agent_id not in session_id_set:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
item = json.loads(message_data)
|
|
||||||
except (TypeError, json.JSONDecodeError):
|
|
||||||
logger.debug("Skipping unreadable SDK session item %s for %s", row_id, agent_id)
|
|
||||||
continue
|
|
||||||
if isinstance(item, dict):
|
|
||||||
items.append((str(agent_id), item, _sqlite_timestamp_to_iso(created_at)))
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def _sqlite_timestamp_to_iso(value: Any) -> str:
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
return datetime.now(UTC).isoformat()
|
|
||||||
text = value.strip()
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(text)
|
|
||||||
except ValueError:
|
|
||||||
return text
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
parsed = parsed.replace(tzinfo=UTC)
|
|
||||||
return parsed.astimezone(UTC).isoformat()
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
"""TUI-owned projection of SDK session history and stream events."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from strix.core.paths import runtime_state_dir
|
|
||||||
from strix.interface.tui.history import load_session_history
|
|
||||||
|
|
||||||
|
|
||||||
class TuiLiveView:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.agents: dict[str, dict[str, Any]] = {}
|
|
||||||
self.events: list[dict[str, Any]] = []
|
|
||||||
self._next_event_id = 1
|
|
||||||
self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {}
|
|
||||||
self._tool_event_by_call_id: dict[str, dict[str, Any]] = {}
|
|
||||||
|
|
||||||
def hydrate_from_run_dir(self, run_dir: Path) -> None:
|
|
||||||
state_dir = runtime_state_dir(run_dir)
|
|
||||||
agents_path = state_dir / "agents.json"
|
|
||||||
if not agents_path.exists():
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
agents_data = json.loads(agents_path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return
|
|
||||||
statuses = agents_data.get("statuses") or {}
|
|
||||||
names = agents_data.get("names") or {}
|
|
||||||
parent_of = agents_data.get("parent_of") or {}
|
|
||||||
if not isinstance(statuses, dict):
|
|
||||||
return
|
|
||||||
for agent_id, status in statuses.items():
|
|
||||||
if not isinstance(agent_id, str):
|
|
||||||
continue
|
|
||||||
self.upsert_agent(
|
|
||||||
agent_id,
|
|
||||||
name=names.get(agent_id, agent_id) if isinstance(names, dict) else agent_id,
|
|
||||||
parent_id=parent_of.get(agent_id) if isinstance(parent_of, dict) else None,
|
|
||||||
status=str(status),
|
|
||||||
)
|
|
||||||
self._hydrate_sdk_session_history(run_dir, statuses.keys())
|
|
||||||
|
|
||||||
def _hydrate_sdk_session_history(self, run_dir: Path, agent_ids: Any) -> None:
|
|
||||||
for agent_id, item, timestamp in load_session_history(run_dir, agent_ids):
|
|
||||||
self._ingest_session_history_item(
|
|
||||||
agent_id,
|
|
||||||
item,
|
|
||||||
timestamp=timestamp,
|
|
||||||
)
|
|
||||||
|
|
||||||
def upsert_agent(
|
|
||||||
self,
|
|
||||||
agent_id: str,
|
|
||||||
*,
|
|
||||||
name: str | None = None,
|
|
||||||
parent_id: str | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
error_message: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
now = datetime.now(UTC).isoformat()
|
|
||||||
current = self.agents.setdefault(
|
|
||||||
agent_id,
|
|
||||||
{
|
|
||||||
"id": agent_id,
|
|
||||||
"name": name or agent_id,
|
|
||||||
"parent_id": parent_id,
|
|
||||||
"status": status or "running",
|
|
||||||
"created_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if name is not None:
|
|
||||||
current["name"] = name
|
|
||||||
if parent_id is not None or "parent_id" not in current:
|
|
||||||
current["parent_id"] = parent_id
|
|
||||||
if status is not None:
|
|
||||||
current["status"] = status
|
|
||||||
if error_message:
|
|
||||||
current["error_message"] = error_message
|
|
||||||
current["updated_at"] = now
|
|
||||||
|
|
||||||
def record_user_message(self, agent_id: str, content: str) -> None:
|
|
||||||
self._append_event(
|
|
||||||
agent_id,
|
|
||||||
"chat",
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": content,
|
|
||||||
"metadata": {"source": "tui_user"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def ingest_sdk_event(self, agent_id: str, event: Any) -> None:
|
|
||||||
event_type = getattr(event, "type", "")
|
|
||||||
if event_type == "raw_response_event":
|
|
||||||
self._ingest_raw_response_event(agent_id, getattr(event, "data", None))
|
|
||||||
return
|
|
||||||
if event_type != "run_item_stream_event":
|
|
||||||
return
|
|
||||||
|
|
||||||
item = getattr(event, "item", None)
|
|
||||||
item_type = getattr(item, "type", "")
|
|
||||||
if item_type == "message_output_item":
|
|
||||||
self._record_assistant_message(agent_id, _sdk_message_text(item), final=True)
|
|
||||||
elif item_type == "tool_call_item":
|
|
||||||
self._record_tool_call(agent_id, item)
|
|
||||||
elif item_type == "tool_call_output_item":
|
|
||||||
self._record_tool_output(agent_id, item)
|
|
||||||
|
|
||||||
def events_for_agent(self, agent_id: str) -> list[dict[str, Any]]:
|
|
||||||
return [event for event in self.events if event.get("agent_id") == agent_id]
|
|
||||||
|
|
||||||
def has_events_for_agent(self, agent_id: str) -> bool:
|
|
||||||
return any(event.get("agent_id") == agent_id for event in self.events)
|
|
||||||
|
|
||||||
def _ingest_raw_response_event(self, agent_id: str, data: Any) -> None:
|
|
||||||
data_type = getattr(data, "type", "")
|
|
||||||
if data_type == "response.output_text.delta":
|
|
||||||
delta = getattr(data, "delta", "")
|
|
||||||
if delta:
|
|
||||||
self._record_assistant_message(agent_id, str(delta), final=False)
|
|
||||||
|
|
||||||
def _ingest_session_history_item(
|
|
||||||
self,
|
|
||||||
agent_id: str,
|
|
||||||
item: dict[str, Any],
|
|
||||||
*,
|
|
||||||
timestamp: str,
|
|
||||||
) -> None:
|
|
||||||
item_type = item.get("type")
|
|
||||||
role = item.get("role")
|
|
||||||
if role in {"user", "assistant"} and (item_type in {None, "message"}):
|
|
||||||
content = _session_message_text(item)
|
|
||||||
if content:
|
|
||||||
self._append_event(
|
|
||||||
agent_id,
|
|
||||||
"chat",
|
|
||||||
{
|
|
||||||
"role": role,
|
|
||||||
"content": content,
|
|
||||||
"metadata": {"source": "sdk_session"},
|
|
||||||
},
|
|
||||||
timestamp=timestamp,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if item_type == "function_call":
|
|
||||||
self._record_tool_call_data(
|
|
||||||
agent_id,
|
|
||||||
{
|
|
||||||
"call_id": str(item.get("call_id") or item.get("id") or ""),
|
|
||||||
"tool_name": str(item.get("name") or "tool"),
|
|
||||||
"args": _parse_json_object(item.get("arguments")),
|
|
||||||
},
|
|
||||||
timestamp=timestamp,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if item_type == "function_call_output":
|
|
||||||
self._record_tool_output_data(
|
|
||||||
agent_id,
|
|
||||||
{
|
|
||||||
"call_id": str(item.get("call_id") or item.get("id") or ""),
|
|
||||||
"tool_name": "tool",
|
|
||||||
"output": item.get("output"),
|
|
||||||
},
|
|
||||||
timestamp=timestamp,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _record_assistant_message(self, agent_id: str, content: str, *, final: bool) -> None:
|
|
||||||
if not content:
|
|
||||||
return
|
|
||||||
existing = self._open_assistant_event_by_agent.get(agent_id)
|
|
||||||
if existing is None:
|
|
||||||
event = self._append_event(
|
|
||||||
agent_id,
|
|
||||||
"chat",
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": content,
|
|
||||||
"metadata": {"source": "sdk_stream", "streaming": not final},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if not final:
|
|
||||||
self._open_assistant_event_by_agent[agent_id] = event
|
|
||||||
return
|
|
||||||
|
|
||||||
data = existing["data"]
|
|
||||||
if final:
|
|
||||||
data["content"] = content
|
|
||||||
data["metadata"]["streaming"] = False
|
|
||||||
self._open_assistant_event_by_agent.pop(agent_id, None)
|
|
||||||
else:
|
|
||||||
data["content"] = f"{data.get('content', '')}{content}"
|
|
||||||
self._bump_event(existing)
|
|
||||||
|
|
||||||
def _record_tool_call(self, agent_id: str, item: Any) -> None:
|
|
||||||
self._record_tool_call_data(agent_id, _sdk_tool_call_data(item))
|
|
||||||
|
|
||||||
def _record_tool_call_data(
|
|
||||||
self,
|
|
||||||
agent_id: str,
|
|
||||||
call: dict[str, Any],
|
|
||||||
*,
|
|
||||||
timestamp: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
call_id = call["call_id"]
|
|
||||||
existing = self._tool_event_by_call_id.get(call_id)
|
|
||||||
tool_data = {
|
|
||||||
"tool_name": call["tool_name"],
|
|
||||||
"args": call["args"],
|
|
||||||
"status": "running",
|
|
||||||
"agent_id": agent_id,
|
|
||||||
"call_id": call_id,
|
|
||||||
}
|
|
||||||
if existing is None:
|
|
||||||
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
|
|
||||||
self._tool_event_by_call_id[call_id] = event
|
|
||||||
else:
|
|
||||||
existing["data"].update(tool_data)
|
|
||||||
self._bump_event(existing, timestamp=timestamp)
|
|
||||||
|
|
||||||
def _record_tool_output(self, agent_id: str, item: Any) -> None:
|
|
||||||
self._record_tool_output_data(agent_id, _sdk_tool_output_data(item))
|
|
||||||
|
|
||||||
def _record_tool_output_data(
|
|
||||||
self,
|
|
||||||
agent_id: str,
|
|
||||||
output: dict[str, Any],
|
|
||||||
*,
|
|
||||||
timestamp: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
call_id = output["call_id"]
|
|
||||||
event = self._tool_event_by_call_id.get(call_id)
|
|
||||||
if event is None:
|
|
||||||
event = self._append_event(
|
|
||||||
agent_id,
|
|
||||||
"tool",
|
|
||||||
{
|
|
||||||
"tool_name": output["tool_name"],
|
|
||||||
"args": {},
|
|
||||||
"status": "completed",
|
|
||||||
"agent_id": agent_id,
|
|
||||||
"call_id": call_id,
|
|
||||||
},
|
|
||||||
timestamp=timestamp,
|
|
||||||
)
|
|
||||||
self._tool_event_by_call_id[call_id] = event
|
|
||||||
|
|
||||||
result = _parse_json_value(output["output"])
|
|
||||||
event["data"]["result"] = result
|
|
||||||
event["data"]["status"] = _tool_status_from_result(result)
|
|
||||||
self._bump_event(event, timestamp=timestamp)
|
|
||||||
|
|
||||||
def _append_event(
|
|
||||||
self,
|
|
||||||
agent_id: str,
|
|
||||||
event_type: str,
|
|
||||||
data: dict[str, Any],
|
|
||||||
*,
|
|
||||||
timestamp: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
event = {
|
|
||||||
"id": f"{event_type}_{self._next_event_id}",
|
|
||||||
"type": event_type,
|
|
||||||
"agent_id": agent_id,
|
|
||||||
"timestamp": timestamp or datetime.now(UTC).isoformat(),
|
|
||||||
"version": 0,
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
self._next_event_id += 1
|
|
||||||
self.events.append(event)
|
|
||||||
return event
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _bump_event(event: dict[str, Any], *, timestamp: str | None = None) -> None:
|
|
||||||
event["version"] = int(event.get("version", 0)) + 1
|
|
||||||
event["timestamp"] = timestamp or datetime.now(UTC).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _sdk_tool_call_data(item: Any) -> dict[str, Any]:
|
|
||||||
raw = getattr(item, "raw_item", None)
|
|
||||||
call_id = str(_raw_field(raw, "call_id") or _raw_field(raw, "id") or id(item))
|
|
||||||
tool_name = str(
|
|
||||||
_raw_field(raw, "name") or _raw_field(raw, "type") or getattr(item, "title", None) or "tool"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"call_id": call_id,
|
|
||||||
"tool_name": tool_name,
|
|
||||||
"args": _parse_json_object(_raw_field(raw, "arguments")),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sdk_tool_output_data(item: Any) -> dict[str, Any]:
|
|
||||||
raw = getattr(item, "raw_item", None)
|
|
||||||
call_id = str(_raw_field(raw, "call_id") or _raw_field(raw, "id") or id(item))
|
|
||||||
return {
|
|
||||||
"call_id": call_id,
|
|
||||||
"tool_name": str(_raw_field(raw, "name") or _raw_field(raw, "type") or "tool"),
|
|
||||||
"output": getattr(item, "output", _raw_field(raw, "output")),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sdk_message_text(item: Any) -> str:
|
|
||||||
raw = getattr(item, "raw_item", None)
|
|
||||||
return _message_content_text(_raw_field(raw, "content", []))
|
|
||||||
|
|
||||||
|
|
||||||
def _session_message_text(item: dict[str, Any]) -> str:
|
|
||||||
return _message_content_text(item.get("content", ""))
|
|
||||||
|
|
||||||
|
|
||||||
def _message_content_text(content: Any) -> str:
|
|
||||||
parts: list[str] = []
|
|
||||||
content_items = content if isinstance(content, list) else [content]
|
|
||||||
for part in content_items:
|
|
||||||
if isinstance(part, str):
|
|
||||||
parts.append(part)
|
|
||||||
continue
|
|
||||||
text = _raw_field(part, "text")
|
|
||||||
if isinstance(text, str):
|
|
||||||
parts.append(text)
|
|
||||||
return "".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _raw_field(raw: Any, key: str, default: Any = None) -> Any:
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
return raw.get(key, default)
|
|
||||||
return getattr(raw, key, default)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_object(value: Any) -> dict[str, Any]:
|
|
||||||
parsed = _parse_json_value(value)
|
|
||||||
return parsed if isinstance(parsed, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_value(value: Any) -> Any:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return value
|
|
||||||
try:
|
|
||||||
return json.loads(value)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_status_from_result(result: Any) -> str:
|
|
||||||
if isinstance(result, dict) and result.get("success") is False:
|
|
||||||
return "failed"
|
|
||||||
return "completed"
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""Message delivery bridge from TUI input to SDK-backed agents."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def send_user_message_to_agent(
|
|
||||||
*,
|
|
||||||
coordinator: Any,
|
|
||||||
loop: asyncio.AbstractEventLoop | None,
|
|
||||||
live_view: Any,
|
|
||||||
target_agent_id: str,
|
|
||||||
message: str,
|
|
||||||
) -> bool:
|
|
||||||
if loop is None or loop.is_closed():
|
|
||||||
return False
|
|
||||||
|
|
||||||
live_view.record_user_message(target_agent_id, message)
|
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
|
||||||
coordinator.send(
|
|
||||||
target_agent_id,
|
|
||||||
{"from": "user", "content": message, "type": "instruction"},
|
|
||||||
),
|
|
||||||
loop,
|
|
||||||
)
|
|
||||||
future.add_done_callback(_log_delivery_failure)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _log_delivery_failure(future: Any) -> None:
|
|
||||||
try:
|
|
||||||
delivered = bool(future.result())
|
|
||||||
except Exception:
|
|
||||||
logger.exception("TUI user message delivery failed")
|
|
||||||
return
|
|
||||||
if not delivered:
|
|
||||||
logger.warning("TUI user message was not persisted to the SDK session")
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from . import (
|
|
||||||
agents_graph_renderer,
|
|
||||||
filesystem_renderer,
|
|
||||||
finish_renderer,
|
|
||||||
load_skill_renderer,
|
|
||||||
notes_renderer,
|
|
||||||
proxy_renderer,
|
|
||||||
reporting_renderer,
|
|
||||||
shell_renderer,
|
|
||||||
thinking_renderer,
|
|
||||||
todo_renderer,
|
|
||||||
web_search_renderer,
|
|
||||||
)
|
|
||||||
from .registry import render_tool_widget
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"agents_graph_renderer",
|
|
||||||
"filesystem_renderer",
|
|
||||||
"finish_renderer",
|
|
||||||
"load_skill_renderer",
|
|
||||||
"notes_renderer",
|
|
||||||
"proxy_renderer",
|
|
||||||
"render_tool_widget",
|
|
||||||
"reporting_renderer",
|
|
||||||
"shell_renderer",
|
|
||||||
"thinking_renderer",
|
|
||||||
"todo_renderer",
|
|
||||||
"web_search_renderer",
|
|
||||||
]
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Any, ClassVar
|
|
||||||
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
|
|
||||||
class BaseToolRenderer(ABC):
|
|
||||||
tool_name: ClassVar[str] = ""
|
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call"]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@abstractmethod
|
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def status_icon(cls, status: str) -> tuple[str, str]:
|
|
||||||
icons = {
|
|
||||||
"running": ("● In progress...", "#f59e0b"),
|
|
||||||
"completed": ("✓ Done", "#22c55e"),
|
|
||||||
"failed": ("✗ Failed", "#dc2626"),
|
|
||||||
"error": ("✗ Error", "#dc2626"),
|
|
||||||
}
|
|
||||||
return icons.get(status, ("○ Unknown", "dim"))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_css_classes(cls, status: str) -> str:
|
|
||||||
base_classes = cls.css_classes.copy()
|
|
||||||
base_classes.append(f"status-{status}")
|
|
||||||
return " ".join(base_classes)
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from functools import cache
|
|
||||||
from typing import Any, ClassVar
|
|
||||||
|
|
||||||
from pygments.lexers import get_lexer_by_name, get_lexer_for_filename
|
|
||||||
from pygments.styles import get_style_by_name
|
|
||||||
from pygments.util import ClassNotFound
|
|
||||||
from rich.text import Text
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
from .base_renderer import BaseToolRenderer
|
|
||||||
from .registry import register_tool_renderer
|
|
||||||
|
|
||||||
|
|
||||||
_ADD_FILE = "*** Add File: "
|
|
||||||
_DELETE_FILE = "*** Delete File: "
|
|
||||||
_UPDATE_FILE = "*** Update File: "
|
|
||||||
_BEGIN_PATCH = "*** Begin Patch"
|
|
||||||
_END_PATCH = "*** End Patch"
|
|
||||||
|
|
||||||
_VIEW_IMAGE_ERROR_PREFIXES = (
|
|
||||||
"image path ",
|
|
||||||
"unable to read image",
|
|
||||||
"manifest path",
|
|
||||||
"exceeded the allowed size",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@cache
|
|
||||||
def _get_style_colors() -> dict[Any, str]:
|
|
||||||
style = get_style_by_name("native")
|
|
||||||
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_lexer_for_file(path: str) -> Any:
|
|
||||||
try:
|
|
||||||
return get_lexer_for_filename(path)
|
|
||||||
except ClassNotFound:
|
|
||||||
return get_lexer_by_name("text")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_token_color(token_type: Any) -> str | None:
|
|
||||||
colors = _get_style_colors()
|
|
||||||
while token_type:
|
|
||||||
if token_type in colors:
|
|
||||||
return colors[token_type]
|
|
||||||
token_type = token_type.parent
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _highlight_code(code: str, path: str) -> Text:
|
|
||||||
lexer = _get_lexer_for_file(path)
|
|
||||||
text = Text()
|
|
||||||
for token_type, token_value in lexer.get_tokens(code):
|
|
||||||
if not token_value:
|
|
||||||
continue
|
|
||||||
color = _get_token_color(token_type)
|
|
||||||
text.append(token_value, style=color)
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_patch_text(args: dict[str, Any]) -> str:
|
|
||||||
"""apply_patch input arrives as either {"patch": text} or raw text in
|
|
||||||
the "input" field, depending on whether the tool is wrapped as a
|
|
||||||
chat-completions FunctionTool or routed through as a CustomTool.
|
|
||||||
"""
|
|
||||||
raw = args.get("patch")
|
|
||||||
if isinstance(raw, str):
|
|
||||||
return raw
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
inner = raw.get("patch")
|
|
||||||
if isinstance(inner, str):
|
|
||||||
return inner
|
|
||||||
fallback = args.get("input") if isinstance(args, dict) else None
|
|
||||||
if isinstance(fallback, str):
|
|
||||||
return fallback
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_patch_operations(
|
|
||||||
patch_text: str,
|
|
||||||
) -> list[tuple[str, str, list[str], list[str]]]:
|
|
||||||
"""Return [(kind, path, old_lines, new_lines), ...] for each file op."""
|
|
||||||
ops: list[tuple[str, str, list[str], list[str]]] = []
|
|
||||||
current_kind: str | None = None
|
|
||||||
current_path: str | None = None
|
|
||||||
old_lines: list[str] = []
|
|
||||||
new_lines: list[str] = []
|
|
||||||
|
|
||||||
def flush() -> None:
|
|
||||||
nonlocal current_kind, current_path, old_lines, new_lines
|
|
||||||
if current_kind and current_path is not None:
|
|
||||||
ops.append((current_kind, current_path, old_lines, new_lines))
|
|
||||||
current_kind = None
|
|
||||||
current_path = None
|
|
||||||
old_lines = []
|
|
||||||
new_lines = []
|
|
||||||
|
|
||||||
for line in patch_text.splitlines():
|
|
||||||
if line in (_BEGIN_PATCH, _END_PATCH):
|
|
||||||
continue
|
|
||||||
if line.startswith(_ADD_FILE):
|
|
||||||
flush()
|
|
||||||
current_kind = "add"
|
|
||||||
current_path = line[len(_ADD_FILE) :].strip()
|
|
||||||
elif line.startswith(_UPDATE_FILE):
|
|
||||||
flush()
|
|
||||||
current_kind = "update"
|
|
||||||
current_path = line[len(_UPDATE_FILE) :].strip()
|
|
||||||
elif line.startswith(_DELETE_FILE):
|
|
||||||
flush()
|
|
||||||
current_kind = "delete"
|
|
||||||
current_path = line[len(_DELETE_FILE) :].strip()
|
|
||||||
elif current_kind == "update":
|
|
||||||
if line.startswith("@@"):
|
|
||||||
continue
|
|
||||||
if line.startswith("-") and not line.startswith("---"):
|
|
||||||
old_lines.append(line[1:])
|
|
||||||
elif line.startswith("+") and not line.startswith("+++"):
|
|
||||||
new_lines.append(line[1:])
|
|
||||||
elif current_kind == "add":
|
|
||||||
if line.startswith("+"):
|
|
||||||
new_lines.append(line[1:])
|
|
||||||
elif line.strip():
|
|
||||||
new_lines.append(line)
|
|
||||||
flush()
|
|
||||||
return ops
|
|
||||||
|
|
||||||
|
|
||||||
_OP_LABEL = {
|
|
||||||
"add": "create",
|
|
||||||
"update": "edit",
|
|
||||||
"delete": "delete",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _render_operation(text: Text, kind: str, path: str, old: list[str], new: list[str]) -> None:
|
|
||||||
label = _OP_LABEL.get(kind, "file")
|
|
||||||
|
|
||||||
text.append("◇ ", style="#10b981")
|
|
||||||
text.append(label, style="dim")
|
|
||||||
|
|
||||||
if path:
|
|
||||||
path_display = path[-60:] if len(path) > 60 else path
|
|
||||||
text.append(" ")
|
|
||||||
text.append(path_display, style="dim")
|
|
||||||
|
|
||||||
if kind == "update":
|
|
||||||
if old:
|
|
||||||
highlighted_old = _highlight_code("\n".join(old), path)
|
|
||||||
for line in highlighted_old.plain.split("\n"):
|
|
||||||
text.append("\n")
|
|
||||||
text.append("-", style="#ef4444")
|
|
||||||
text.append(" ")
|
|
||||||
text.append(line)
|
|
||||||
if new:
|
|
||||||
highlighted_new = _highlight_code("\n".join(new), path)
|
|
||||||
for line in highlighted_new.plain.split("\n"):
|
|
||||||
text.append("\n")
|
|
||||||
text.append("+", style="#22c55e")
|
|
||||||
text.append(" ")
|
|
||||||
text.append(line)
|
|
||||||
elif kind == "add" and new:
|
|
||||||
text.append("\n")
|
|
||||||
text.append_text(_highlight_code("\n".join(new), path))
|
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
|
||||||
class ApplyPatchRenderer(BaseToolRenderer):
|
|
||||||
tool_name: ClassVar[str] = "apply_patch"
|
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
|
||||||
args = tool_data.get("args", {})
|
|
||||||
result = tool_data.get("result")
|
|
||||||
status = tool_data.get("status", "completed")
|
|
||||||
|
|
||||||
patch_text = _extract_patch_text(args)
|
|
||||||
ops = _parse_patch_operations(patch_text)
|
|
||||||
|
|
||||||
text = Text()
|
|
||||||
|
|
||||||
if not ops:
|
|
||||||
text.append("◇ ", style="#10b981")
|
|
||||||
text.append("patch", style="dim")
|
|
||||||
if isinstance(result, str) and result.strip():
|
|
||||||
text.append("\n ")
|
|
||||||
text.append(result.strip(), style="dim")
|
|
||||||
elif not result:
|
|
||||||
text.append(" ")
|
|
||||||
text.append("Processing...", style="dim")
|
|
||||||
return Static(text, classes=cls.get_css_classes(status))
|
|
||||||
|
|
||||||
for i, (kind, path, old, new) in enumerate(ops):
|
|
||||||
if i > 0:
|
|
||||||
text.append("\n")
|
|
||||||
_render_operation(text, kind, path, old, new)
|
|
||||||
|
|
||||||
if status == "failed" and isinstance(result, str) and result.strip():
|
|
||||||
text.append("\n ")
|
|
||||||
text.append(result.strip(), style="#ef4444")
|
|
||||||
|
|
||||||
return Static(text, classes=cls.get_css_classes(status))
|
|
||||||
|
|
||||||
|
|
||||||
def _is_image_success(result: Any) -> bool:
|
|
||||||
if isinstance(result, dict) and result.get("type") == "image":
|
|
||||||
return True
|
|
||||||
if isinstance(result, str):
|
|
||||||
stripped = result.lstrip()
|
|
||||||
if stripped.startswith("data:image/"):
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
obj = json.loads(stripped)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
return isinstance(obj, dict) and obj.get("type") == "image"
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _image_error_text(result: Any) -> str | None:
|
|
||||||
if not isinstance(result, str):
|
|
||||||
return None
|
|
||||||
stripped = result.strip()
|
|
||||||
if not stripped:
|
|
||||||
return None
|
|
||||||
lower = stripped.lower()
|
|
||||||
if lower.startswith(_VIEW_IMAGE_ERROR_PREFIXES) or "not a supported image" in lower:
|
|
||||||
return stripped
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
|
||||||
class ViewImageRenderer(BaseToolRenderer):
|
|
||||||
tool_name: ClassVar[str] = "view_image"
|
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
|
||||||
args = tool_data.get("args", {})
|
|
||||||
result = tool_data.get("result")
|
|
||||||
status = tool_data.get("status", "completed")
|
|
||||||
|
|
||||||
path = str(args.get("path", "")).strip()
|
|
||||||
|
|
||||||
text = Text()
|
|
||||||
text.append("◇ ", style="#10b981")
|
|
||||||
text.append("view image", style="dim")
|
|
||||||
|
|
||||||
if path:
|
|
||||||
path_display = path[-60:] if len(path) > 60 else path
|
|
||||||
text.append(" ")
|
|
||||||
text.append(path_display, style="dim")
|
|
||||||
|
|
||||||
err = _image_error_text(result)
|
|
||||||
if err is not None:
|
|
||||||
text.append("\n ")
|
|
||||||
text.append(err, style="#ef4444")
|
|
||||||
elif _is_image_success(result):
|
|
||||||
text.append(" ")
|
|
||||||
text.append("✓", style="#22c55e")
|
|
||||||
|
|
||||||
return Static(text, classes=cls.get_css_classes(status))
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
import re
|
|
||||||
from functools import cache
|
|
||||||
from typing import Any, ClassVar
|
|
||||||
|
|
||||||
from pygments.lexers import get_lexer_by_name
|
|
||||||
from pygments.styles import get_style_by_name
|
|
||||||
from rich.text import Text
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
from .base_renderer import BaseToolRenderer
|
|
||||||
from .registry import register_tool_renderer
|
|
||||||
|
|
||||||
|
|
||||||
MAX_OUTPUT_LINES = 50
|
|
||||||
MAX_LINE_LENGTH = 200
|
|
||||||
|
|
||||||
STRIP_PATTERNS = [
|
|
||||||
r"^Chunk ID: [0-9a-f]+\s*$",
|
|
||||||
r"^Wall time: [\d.]+ seconds\s*$",
|
|
||||||
r"^Process exited with code -?\d+\s*$",
|
|
||||||
r"^Process running with session ID \d+\s*$",
|
|
||||||
r"^Original token count: \d+\s*$",
|
|
||||||
]
|
|
||||||
|
|
||||||
_EXIT_RE = re.compile(r"Process exited with code (-?\d+)")
|
|
||||||
_SESSION_RE = re.compile(r"Process running with session ID (\d+)")
|
|
||||||
_OUTPUT_HEADER = "\nOutput:\n"
|
|
||||||
|
|
||||||
_CONTROL_BYTES_TO_DROP = dict.fromkeys(
|
|
||||||
[b for b in range(0x20) if b not in (0x09, 0x0A)] + [0x7F],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@cache
|
|
||||||
def _get_style_colors() -> dict[Any, str]:
|
|
||||||
style = get_style_by_name("native")
|
|
||||||
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_sdk_shell_result(result: Any) -> dict[str, Any]:
|
|
||||||
"""Translate the SDK's terminal-output string into the dict shape the
|
|
||||||
renderer's `_append_output` helper expects.
|
|
||||||
|
|
||||||
The SDK returns a header-prefixed string ending with `Output:\\n<actual>`.
|
|
||||||
We extract `content`, `exit_code`, and `session_id`; anything else (or a
|
|
||||||
non-string result) flows through unchanged so renderers can handle errors.
|
|
||||||
"""
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return result
|
|
||||||
if not isinstance(result, str):
|
|
||||||
return {"content": "" if result is None else str(result)}
|
|
||||||
|
|
||||||
exit_match = _EXIT_RE.search(result)
|
|
||||||
session_match = _SESSION_RE.search(result)
|
|
||||||
idx = result.find(_OUTPUT_HEADER)
|
|
||||||
content = result[idx + len(_OUTPUT_HEADER) :] if idx >= 0 else result
|
|
||||||
|
|
||||||
parsed: dict[str, Any] = {"content": content}
|
|
||||||
if exit_match:
|
|
||||||
parsed["exit_code"] = int(exit_match.group(1))
|
|
||||||
if session_match:
|
|
||||||
parsed["session_id"] = int(session_match.group(1))
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _truncate_line(line: str) -> str:
|
|
||||||
if len(line) > MAX_LINE_LENGTH:
|
|
||||||
return line[: MAX_LINE_LENGTH - 3] + "..."
|
|
||||||
return line
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_output(output: str) -> str:
|
|
||||||
cleaned = Text.from_ansi(output).plain.translate(_CONTROL_BYTES_TO_DROP)
|
|
||||||
for pattern in STRIP_PATTERNS:
|
|
||||||
cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE)
|
|
||||||
|
|
||||||
if cleaned.strip():
|
|
||||||
lines = cleaned.splitlines()
|
|
||||||
filtered_lines: list[str] = []
|
|
||||||
for line in lines:
|
|
||||||
if not filtered_lines and not line.strip():
|
|
||||||
continue
|
|
||||||
if line.strip() == "Output:":
|
|
||||||
continue
|
|
||||||
filtered_lines.append(line)
|
|
||||||
while filtered_lines and not filtered_lines[-1].strip():
|
|
||||||
filtered_lines.pop()
|
|
||||||
cleaned = "\n".join(filtered_lines)
|
|
||||||
|
|
||||||
return cleaned.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _format_output(output: str) -> Text:
|
|
||||||
text = Text()
|
|
||||||
lines = output.splitlines()
|
|
||||||
total_lines = len(lines)
|
|
||||||
|
|
||||||
head_count = MAX_OUTPUT_LINES // 2
|
|
||||||
tail_count = MAX_OUTPUT_LINES - head_count - 1
|
|
||||||
|
|
||||||
if total_lines <= MAX_OUTPUT_LINES:
|
|
||||||
display_lines = lines
|
|
||||||
truncated = False
|
|
||||||
hidden_count = 0
|
|
||||||
else:
|
|
||||||
display_lines = lines[:head_count]
|
|
||||||
truncated = True
|
|
||||||
hidden_count = total_lines - head_count - tail_count
|
|
||||||
|
|
||||||
for i, line in enumerate(display_lines):
|
|
||||||
text.append(" ")
|
|
||||||
text.append(_truncate_line(line), style="dim")
|
|
||||||
if i < len(display_lines) - 1 or truncated:
|
|
||||||
text.append("\n")
|
|
||||||
|
|
||||||
if truncated:
|
|
||||||
text.append(f" ... {hidden_count} lines truncated ...", style="dim italic")
|
|
||||||
text.append("\n")
|
|
||||||
tail_lines = lines[-tail_count:]
|
|
||||||
for i, line in enumerate(tail_lines):
|
|
||||||
text.append(" ")
|
|
||||||
text.append(_truncate_line(line), style="dim")
|
|
||||||
if i < len(tail_lines) - 1:
|
|
||||||
text.append("\n")
|
|
||||||
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _get_token_color(token_type: Any) -> str | None:
|
|
||||||
colors = _get_style_colors()
|
|
||||||
while token_type:
|
|
||||||
if token_type in colors:
|
|
||||||
return colors[token_type]
|
|
||||||
token_type = token_type.parent
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _highlight_bash(code: str) -> Text:
|
|
||||||
lexer = get_lexer_by_name("bash")
|
|
||||||
text = Text()
|
|
||||||
for token_type, token_value in lexer.get_tokens(code):
|
|
||||||
if not token_value:
|
|
||||||
continue
|
|
||||||
color = _get_token_color(token_type)
|
|
||||||
text.append(token_value, style=color)
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _append_output(text: Text, parsed: dict[str, Any], tool_status: str) -> None:
|
|
||||||
raw_output = parsed.get("content", "") or ""
|
|
||||||
output = _clean_output(raw_output) if isinstance(raw_output, str) else ""
|
|
||||||
exit_code = parsed.get("exit_code")
|
|
||||||
|
|
||||||
if tool_status == "running":
|
|
||||||
if output:
|
|
||||||
text.append("\n")
|
|
||||||
text.append_text(_format_output(output))
|
|
||||||
return
|
|
||||||
|
|
||||||
if not output:
|
|
||||||
if exit_code is not None and exit_code != 0:
|
|
||||||
text.append("\n")
|
|
||||||
text.append(f" exit {exit_code}", style="dim #ef4444")
|
|
||||||
return
|
|
||||||
|
|
||||||
text.append("\n")
|
|
||||||
text.append_text(_format_output(output))
|
|
||||||
|
|
||||||
if exit_code is not None and exit_code != 0:
|
|
||||||
text.append("\n")
|
|
||||||
text.append(f" exit {exit_code}", style="dim #ef4444")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_terminal_content(
|
|
||||||
*,
|
|
||||||
prompt: str,
|
|
||||||
prompt_style: str,
|
|
||||||
command: str,
|
|
||||||
parsed_result: dict[str, Any] | None,
|
|
||||||
tool_status: str,
|
|
||||||
meta: str | None = None,
|
|
||||||
) -> Text:
|
|
||||||
text = Text()
|
|
||||||
text.append(">_", style="dim")
|
|
||||||
text.append(" ")
|
|
||||||
|
|
||||||
if not command.strip():
|
|
||||||
text.append("getting logs...", style="dim")
|
|
||||||
else:
|
|
||||||
text.append(prompt, style=prompt_style)
|
|
||||||
text.append(" ")
|
|
||||||
text.append_text(_highlight_bash(command))
|
|
||||||
|
|
||||||
if meta:
|
|
||||||
text.append(f" {meta}", style="dim")
|
|
||||||
|
|
||||||
if parsed_result is not None:
|
|
||||||
_append_output(text, parsed_result, tool_status)
|
|
||||||
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
|
||||||
class ExecCommandRenderer(BaseToolRenderer):
|
|
||||||
tool_name: ClassVar[str] = "exec_command"
|
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
|
||||||
args = tool_data.get("args", {})
|
|
||||||
status = tool_data.get("status", "unknown")
|
|
||||||
result = tool_data.get("result")
|
|
||||||
|
|
||||||
cmd = str(args.get("cmd", ""))
|
|
||||||
workdir = args.get("workdir")
|
|
||||||
tty = bool(args.get("tty"))
|
|
||||||
|
|
||||||
meta_parts: list[str] = []
|
|
||||||
if workdir:
|
|
||||||
meta_parts.append(f"cwd:{workdir}")
|
|
||||||
if tty:
|
|
||||||
meta_parts.append("tty")
|
|
||||||
meta = ", ".join(meta_parts) if meta_parts else None
|
|
||||||
|
|
||||||
parsed = _parse_sdk_shell_result(result) if result is not None else None
|
|
||||||
|
|
||||||
content = _build_terminal_content(
|
|
||||||
prompt="$",
|
|
||||||
prompt_style="#22c55e",
|
|
||||||
command=cmd,
|
|
||||||
parsed_result=parsed,
|
|
||||||
tool_status=status,
|
|
||||||
meta=meta,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Static(content, classes=cls.get_css_classes(status))
|
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
|
||||||
class WriteStdinRenderer(BaseToolRenderer):
|
|
||||||
tool_name: ClassVar[str] = "write_stdin"
|
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
|
||||||
args = tool_data.get("args", {})
|
|
||||||
status = tool_data.get("status", "unknown")
|
|
||||||
result = tool_data.get("result")
|
|
||||||
|
|
||||||
chars = str(args.get("chars", ""))
|
|
||||||
session_id = args.get("session_id")
|
|
||||||
meta = f"session #{session_id}" if session_id is not None else None
|
|
||||||
|
|
||||||
parsed = _parse_sdk_shell_result(result) if result is not None else None
|
|
||||||
|
|
||||||
content = _build_terminal_content(
|
|
||||||
prompt=">>>",
|
|
||||||
prompt_style="#3b82f6",
|
|
||||||
command=chars,
|
|
||||||
parsed_result=parsed,
|
|
||||||
tool_status=status,
|
|
||||||
meta=meta,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Static(content, classes=cls.get_css_classes(status))
|
|
||||||
+144
-237
@@ -1,6 +1,5 @@
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
@@ -21,12 +20,18 @@ from rich.console import Console
|
|||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from strix.config import load_settings
|
|
||||||
|
# Token formatting utilities
|
||||||
|
def format_token_count(count: float) -> str:
|
||||||
logger = logging.getLogger(__name__)
|
count = int(count)
|
||||||
|
if count >= 1_000_000:
|
||||||
|
return f"{count / 1_000_000:.1f}M"
|
||||||
|
if count >= 1_000:
|
||||||
|
return f"{count / 1_000:.1f}K"
|
||||||
|
return str(count)
|
||||||
|
|
||||||
|
|
||||||
|
# Display utilities
|
||||||
def get_severity_color(severity: str) -> str:
|
def get_severity_color(severity: str) -> str:
|
||||||
severity_colors = {
|
severity_colors = {
|
||||||
"critical": "#dc2626",
|
"critical": "#dc2626",
|
||||||
@@ -50,16 +55,8 @@ def get_cvss_color(cvss_score: float) -> str:
|
|||||||
return "#6b7280"
|
return "#6b7280"
|
||||||
|
|
||||||
|
|
||||||
def format_token_count(count: float | None) -> str:
|
def format_vulnerability_report(report: dict[str, Any]) -> Text: # noqa: PLR0912, PLR0915
|
||||||
value = int(count or 0)
|
"""Format a vulnerability report for CLI display with all rich fields."""
|
||||||
if value >= 1_000_000:
|
|
||||||
return f"{value / 1_000_000:.1f}M"
|
|
||||||
if value >= 1_000:
|
|
||||||
return f"{value / 1_000:.1f}K"
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def format_vulnerability_report(report: dict[str, Any]) -> Text: # noqa: PLR0915
|
|
||||||
field_style = "bold #4ade80"
|
field_style = "bold #4ade80"
|
||||||
|
|
||||||
text = Text()
|
text = Text()
|
||||||
@@ -207,12 +204,13 @@ def format_vulnerability_report(report: dict[str, Any]) -> Text: # noqa: PLR091
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _build_vulnerability_stats(stats_text: Text, report_state: Any) -> None:
|
def _build_vulnerability_stats(stats_text: Text, tracer: Any) -> None:
|
||||||
vuln_count = len(report_state.vulnerability_reports)
|
"""Build vulnerability section of stats text."""
|
||||||
|
vuln_count = len(tracer.vulnerability_reports)
|
||||||
|
|
||||||
if vuln_count > 0:
|
if vuln_count > 0:
|
||||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||||
for report in report_state.vulnerability_reports:
|
for report in tracer.vulnerability_reports:
|
||||||
severity = report.get("severity", "").lower()
|
severity = report.get("severity", "").lower()
|
||||||
if severity in severity_counts:
|
if severity in severity_counts:
|
||||||
severity_counts[severity] += 1
|
severity_counts[severity] += 1
|
||||||
@@ -245,107 +243,82 @@ def _build_vulnerability_stats(stats_text: Text, report_state: Any) -> None:
|
|||||||
stats_text.append("\n")
|
stats_text.append("\n")
|
||||||
|
|
||||||
|
|
||||||
def _llm_usage(report_state: Any) -> dict[str, Any]:
|
def _build_llm_stats(stats_text: Text, total_stats: dict[str, Any]) -> None:
|
||||||
if hasattr(report_state, "get_total_llm_usage"):
|
"""Build LLM usage section of stats text."""
|
||||||
usage = report_state.get_total_llm_usage()
|
if total_stats["requests"] > 0:
|
||||||
return usage if isinstance(usage, dict) else {}
|
stats_text.append("\n")
|
||||||
usage = getattr(report_state, "run_record", {}).get("llm_usage")
|
stats_text.append("Input Tokens ", style="dim")
|
||||||
return usage if isinstance(usage, dict) else {}
|
stats_text.append(format_token_count(total_stats["input_tokens"]), style="white")
|
||||||
|
|
||||||
|
if total_stats["cached_tokens"] > 0:
|
||||||
|
stats_text.append(" · ", style="dim white")
|
||||||
|
stats_text.append("Cached Tokens ", style="dim")
|
||||||
|
stats_text.append(format_token_count(total_stats["cached_tokens"]), style="white")
|
||||||
|
|
||||||
def _int_stat(usage: dict[str, Any], key: str) -> int:
|
stats_text.append(" · ", style="dim white")
|
||||||
try:
|
stats_text.append("Output Tokens ", style="dim")
|
||||||
return max(0, int(usage.get(key) or 0))
|
stats_text.append(format_token_count(total_stats["output_tokens"]), style="white")
|
||||||
except (TypeError, ValueError):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
if total_stats["cost"] > 0:
|
||||||
def _float_stat(usage: dict[str, Any], key: str) -> float:
|
stats_text.append(" · ", style="dim white")
|
||||||
try:
|
stats_text.append("Cost ", style="dim")
|
||||||
value = float(usage.get(key) or 0.0)
|
stats_text.append(f"${total_stats['cost']:.4f}", style="bold #fbbf24")
|
||||||
except (TypeError, ValueError):
|
else:
|
||||||
return 0.0
|
|
||||||
return value if value > 0 else 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _detail_value(usage: dict[str, Any], detail_key: str, value_key: str) -> int:
|
|
||||||
details = usage.get(detail_key)
|
|
||||||
if isinstance(details, list):
|
|
||||||
details = details[0] if details and isinstance(details[0], dict) else {}
|
|
||||||
if not isinstance(details, dict):
|
|
||||||
return 0
|
|
||||||
return _int_stat(details, value_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_llm_usage_stats(
|
|
||||||
stats_text: Text,
|
|
||||||
report_state: Any,
|
|
||||||
*,
|
|
||||||
live: bool = False,
|
|
||||||
) -> None:
|
|
||||||
usage = _llm_usage(report_state)
|
|
||||||
if not usage or _int_stat(usage, "requests") <= 0:
|
|
||||||
stats_text.append("\n")
|
stats_text.append("\n")
|
||||||
stats_text.append("Cost ", style="dim")
|
stats_text.append("Cost ", style="dim")
|
||||||
stats_text.append("$0.0000 ", style="#fbbf24")
|
stats_text.append("$0.0000 ", style="#fbbf24")
|
||||||
stats_text.append("· ", style="dim white")
|
stats_text.append("· ", style="dim white")
|
||||||
stats_text.append("Tokens ", style="dim")
|
stats_text.append("Tokens ", style="dim")
|
||||||
stats_text.append("0", style="white")
|
stats_text.append("0", style="white")
|
||||||
return
|
|
||||||
|
|
||||||
input_tokens = _int_stat(usage, "input_tokens")
|
|
||||||
output_tokens = _int_stat(usage, "output_tokens")
|
|
||||||
cached_tokens = _detail_value(usage, "input_tokens_details", "cached_tokens")
|
|
||||||
cost = _float_stat(usage, "cost")
|
|
||||||
|
|
||||||
stats_text.append("\n")
|
|
||||||
stats_text.append("Input Tokens ", style="dim")
|
|
||||||
stats_text.append(format_token_count(input_tokens), style="white")
|
|
||||||
|
|
||||||
if live or cached_tokens > 0:
|
|
||||||
stats_text.append(" · ", style="dim white")
|
|
||||||
stats_text.append("Cached Tokens ", style="dim")
|
|
||||||
stats_text.append(format_token_count(cached_tokens), style="white")
|
|
||||||
|
|
||||||
separator = "\n" if live else " · "
|
|
||||||
stats_text.append(separator, style="dim white")
|
|
||||||
stats_text.append("Output Tokens ", style="dim")
|
|
||||||
stats_text.append(format_token_count(output_tokens), style="white")
|
|
||||||
|
|
||||||
if live or cost > 0:
|
|
||||||
stats_text.append(" · ", style="dim white")
|
|
||||||
stats_text.append("Cost ", style="dim")
|
|
||||||
stats_text.append(f"${cost:.4f}", style="#fbbf24")
|
|
||||||
|
|
||||||
|
|
||||||
def build_final_stats_text(report_state: Any) -> Text:
|
def build_final_stats_text(tracer: Any) -> Text:
|
||||||
|
"""Build stats text for final output with detailed messages and LLM usage."""
|
||||||
stats_text = Text()
|
stats_text = Text()
|
||||||
if not report_state:
|
if not tracer:
|
||||||
return stats_text
|
return stats_text
|
||||||
|
|
||||||
_build_vulnerability_stats(stats_text, report_state)
|
_build_vulnerability_stats(stats_text, tracer)
|
||||||
_build_llm_usage_stats(stats_text, report_state)
|
|
||||||
|
tool_count = tracer.get_real_tool_count()
|
||||||
|
agent_count = len(tracer.agents)
|
||||||
|
|
||||||
|
stats_text.append("Agents", style="dim")
|
||||||
|
stats_text.append(" ")
|
||||||
|
stats_text.append(str(agent_count), style="bold white")
|
||||||
|
stats_text.append(" · ", style="dim white")
|
||||||
|
stats_text.append("Tools", style="dim")
|
||||||
|
stats_text.append(" ")
|
||||||
|
stats_text.append(str(tool_count), style="bold white")
|
||||||
|
|
||||||
|
llm_stats = tracer.get_total_llm_stats()
|
||||||
|
_build_llm_stats(stats_text, llm_stats["total"])
|
||||||
|
|
||||||
return stats_text
|
return stats_text
|
||||||
|
|
||||||
|
|
||||||
def build_live_stats_text(report_state: Any) -> Text:
|
def build_live_stats_text(tracer: Any, agent_config: dict[str, Any] | None = None) -> Text:
|
||||||
stats_text = Text()
|
stats_text = Text()
|
||||||
if not report_state:
|
if not tracer:
|
||||||
return stats_text
|
return stats_text
|
||||||
|
|
||||||
model = load_settings().llm.model or "unknown"
|
if agent_config:
|
||||||
stats_text.append("Model ", style="dim")
|
llm_config = agent_config["llm_config"]
|
||||||
stats_text.append(str(model), style="white")
|
model = getattr(llm_config, "model_name", "Unknown")
|
||||||
stats_text.append("\n")
|
stats_text.append("Model ", style="dim")
|
||||||
|
stats_text.append(model, style="white")
|
||||||
|
stats_text.append("\n")
|
||||||
|
|
||||||
|
vuln_count = len(tracer.vulnerability_reports)
|
||||||
|
tool_count = tracer.get_real_tool_count()
|
||||||
|
agent_count = len(tracer.agents)
|
||||||
|
|
||||||
vuln_count = len(report_state.vulnerability_reports)
|
|
||||||
stats_text.append("Vulnerabilities ", style="dim")
|
stats_text.append("Vulnerabilities ", style="dim")
|
||||||
stats_text.append(f"{vuln_count}", style="white")
|
stats_text.append(f"{vuln_count}", style="white")
|
||||||
stats_text.append("\n")
|
stats_text.append("\n")
|
||||||
if vuln_count > 0:
|
if vuln_count > 0:
|
||||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||||
for report in report_state.vulnerability_reports:
|
for report in tracer.vulnerability_reports:
|
||||||
severity = report.get("severity", "").lower()
|
severity = report.get("severity", "").lower()
|
||||||
if severity in severity_counts:
|
if severity in severity_counts:
|
||||||
severity_counts[severity] += 1
|
severity_counts[severity] += 1
|
||||||
@@ -367,32 +340,59 @@ def build_live_stats_text(report_state: Any) -> Text:
|
|||||||
|
|
||||||
stats_text.append("\n")
|
stats_text.append("\n")
|
||||||
|
|
||||||
_build_llm_usage_stats(stats_text, report_state, live=True)
|
stats_text.append("Agents ", style="dim")
|
||||||
|
stats_text.append(str(agent_count), style="white")
|
||||||
|
stats_text.append(" · ", style="dim white")
|
||||||
|
stats_text.append("Tools ", style="dim")
|
||||||
|
stats_text.append(str(tool_count), style="white")
|
||||||
|
|
||||||
|
llm_stats = tracer.get_total_llm_stats()
|
||||||
|
total_stats = llm_stats["total"]
|
||||||
|
|
||||||
|
stats_text.append("\n")
|
||||||
|
|
||||||
|
stats_text.append("Input Tokens ", style="dim")
|
||||||
|
stats_text.append(format_token_count(total_stats["input_tokens"]), style="white")
|
||||||
|
|
||||||
|
stats_text.append(" · ", style="dim white")
|
||||||
|
stats_text.append("Cached Tokens ", style="dim")
|
||||||
|
stats_text.append(format_token_count(total_stats["cached_tokens"]), style="white")
|
||||||
|
|
||||||
|
stats_text.append("\n")
|
||||||
|
|
||||||
|
stats_text.append("Output Tokens ", style="dim")
|
||||||
|
stats_text.append(format_token_count(total_stats["output_tokens"]), style="white")
|
||||||
|
|
||||||
|
stats_text.append(" · ", style="dim white")
|
||||||
|
stats_text.append("Cost ", style="dim")
|
||||||
|
stats_text.append(f"${total_stats['cost']:.4f}", style="#fbbf24")
|
||||||
|
|
||||||
return stats_text
|
return stats_text
|
||||||
|
|
||||||
|
|
||||||
def build_tui_stats_text(report_state: Any) -> Text:
|
def build_tui_stats_text(tracer: Any, agent_config: dict[str, Any] | None = None) -> Text:
|
||||||
stats_text = Text()
|
stats_text = Text()
|
||||||
if not report_state:
|
if not tracer:
|
||||||
return stats_text
|
return stats_text
|
||||||
|
|
||||||
model = load_settings().llm.model or "unknown"
|
if agent_config:
|
||||||
stats_text.append(str(model), style="white")
|
llm_config = agent_config["llm_config"]
|
||||||
|
model = getattr(llm_config, "model_name", "Unknown")
|
||||||
|
stats_text.append(model, style="white")
|
||||||
|
|
||||||
usage = _llm_usage(report_state)
|
llm_stats = tracer.get_total_llm_stats()
|
||||||
if usage and _int_stat(usage, "total_tokens") > 0:
|
total_stats = llm_stats["total"]
|
||||||
|
|
||||||
|
total_tokens = total_stats["input_tokens"] + total_stats["output_tokens"]
|
||||||
|
if total_tokens > 0:
|
||||||
stats_text.append("\n")
|
stats_text.append("\n")
|
||||||
stats_text.append(
|
stats_text.append(f"{format_token_count(total_tokens)} tokens", style="white")
|
||||||
f"{format_token_count(_int_stat(usage, 'total_tokens'))} tokens",
|
|
||||||
style="white",
|
|
||||||
)
|
|
||||||
cost = _float_stat(usage, "cost")
|
|
||||||
if cost > 0:
|
|
||||||
stats_text.append(" · ", style="white")
|
|
||||||
stats_text.append(f"${cost:.2f}", style="white")
|
|
||||||
|
|
||||||
caido_url = getattr(report_state, "caido_url", None)
|
if total_stats["cost"] > 0:
|
||||||
|
stats_text.append(" · ", style="white")
|
||||||
|
stats_text.append(f"${total_stats['cost']:.2f}", style="white")
|
||||||
|
|
||||||
|
caido_url = getattr(tracer, "caido_url", None)
|
||||||
if caido_url:
|
if caido_url:
|
||||||
stats_text.append("\n")
|
stats_text.append("\n")
|
||||||
stats_text.append("Caido: ", style="bold white")
|
stats_text.append("Caido: ", style="bold white")
|
||||||
@@ -401,6 +401,9 @@ def build_tui_stats_text(report_state: Any) -> Text:
|
|||||||
return stats_text
|
return stats_text
|
||||||
|
|
||||||
|
|
||||||
|
# Name generation utilities
|
||||||
|
|
||||||
|
|
||||||
def _slugify_for_run_name(text: str, max_length: int = 32) -> str:
|
def _slugify_for_run_name(text: str, max_length: int = 32) -> str:
|
||||||
text = text.lower().strip()
|
text = text.lower().strip()
|
||||||
text = re.sub(r"[^a-z0-9]+", "-", text)
|
text = re.sub(r"[^a-z0-9]+", "-", text)
|
||||||
@@ -424,7 +427,7 @@ def _derive_target_label_for_run_name(targets_info: list[dict[str, Any]] | None)
|
|||||||
try:
|
try:
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
return str(parsed.netloc or parsed.path or url)
|
return str(parsed.netloc or parsed.path or url)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001
|
||||||
return str(url)
|
return str(url)
|
||||||
|
|
||||||
if target_type == "repository":
|
if target_type == "repository":
|
||||||
@@ -440,7 +443,7 @@ def _derive_target_label_for_run_name(targets_info: list[dict[str, Any]] | None)
|
|||||||
path_str = details.get("target_path", original)
|
path_str = details.get("target_path", original)
|
||||||
try:
|
try:
|
||||||
return str(Path(path_str).name or path_str)
|
return str(Path(path_str).name or path_str)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001
|
||||||
return str(path_str)
|
return str(path_str)
|
||||||
|
|
||||||
if target_type == "ip_address":
|
if target_type == "ip_address":
|
||||||
@@ -458,6 +461,8 @@ def generate_run_name(targets_info: list[dict[str, Any]] | None = None) -> str:
|
|||||||
return f"{slug}_{random_suffix}"
|
return f"{slug}_{random_suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
# Target processing utilities
|
||||||
|
|
||||||
_SUPPORTED_SCOPE_MODES = {"auto", "diff", "full"}
|
_SUPPORTED_SCOPE_MODES = {"auto", "diff", "full"}
|
||||||
_MAX_FILES_PER_SECTION = 120
|
_MAX_FILES_PER_SECTION = 120
|
||||||
|
|
||||||
@@ -707,6 +712,9 @@ def _parse_name_status_z(raw_output: bytes) -> list[DiffEntry]:
|
|||||||
if len(status_raw) > 1 and status_raw[1:].isdigit():
|
if len(status_raw) > 1 and status_raw[1:].isdigit():
|
||||||
similarity = int(status_raw[1:])
|
similarity = int(status_raw[1:])
|
||||||
|
|
||||||
|
# Git's -z output for --name-status is:
|
||||||
|
# - non-rename/copy: <status>\0<path>\0
|
||||||
|
# - rename/copy: <statusN>\0<old_path>\0<new_path>\0
|
||||||
if status_code in {"R", "C"} and index + 2 < len(tokens):
|
if status_code in {"R", "C"} and index + 2 < len(tokens):
|
||||||
old_path = tokens[index + 1]
|
old_path = tokens[index + 1]
|
||||||
new_path = tokens[index + 2]
|
new_path = tokens[index + 2]
|
||||||
@@ -727,7 +735,18 @@ def _parse_name_status_z(raw_output: bytes) -> list[DiffEntry]:
|
|||||||
index += 2
|
index += 2
|
||||||
continue
|
continue
|
||||||
|
|
||||||
break
|
# Backward-compat fallback if output is tab-delimited unexpectedly.
|
||||||
|
status_fallback, has_tab, first_path = token.partition("\t")
|
||||||
|
if not has_tab:
|
||||||
|
break
|
||||||
|
fallback_code = status_fallback[:1]
|
||||||
|
fallback_similarity: int | None = None
|
||||||
|
if len(status_fallback) > 1 and status_fallback[1:].isdigit():
|
||||||
|
fallback_similarity = int(status_fallback[1:])
|
||||||
|
entries.append(
|
||||||
|
DiffEntry(status=fallback_code, path=first_path, similarity=fallback_similarity)
|
||||||
|
)
|
||||||
|
index += 1
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
@@ -804,7 +823,7 @@ def _truncate_file_list(
|
|||||||
return files[:max_files], True
|
return files[:max_files], True
|
||||||
|
|
||||||
|
|
||||||
def build_diff_scope_instruction(scopes: list[RepoDiffScope]) -> str:
|
def build_diff_scope_instruction(scopes: list[RepoDiffScope]) -> str: # noqa: PLR0912
|
||||||
lines = [
|
lines = [
|
||||||
"The user is requesting a review of a Pull Request.",
|
"The user is requesting a review of a Pull Request.",
|
||||||
"Instruction: Direct your analysis primarily at the changes in the listed files. "
|
"Instruction: Direct your analysis primarily at the changes in the listed files. "
|
||||||
@@ -1030,7 +1049,7 @@ def resolve_diff_scope_context(
|
|||||||
)
|
)
|
||||||
|
|
||||||
instruction_block = build_diff_scope_instruction(repo_scopes)
|
instruction_block = build_diff_scope_instruction(repo_scopes)
|
||||||
metadata = {
|
metadata: dict[str, Any] = {
|
||||||
"active": True,
|
"active": True,
|
||||||
"mode": scope_mode,
|
"mode": scope_mode,
|
||||||
"repos": [scope.to_metadata() for scope in repo_scopes],
|
"repos": [scope.to_metadata() for scope in repo_scopes],
|
||||||
@@ -1063,7 +1082,7 @@ def _is_http_git_repo(url: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911
|
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911, PLR0912
|
||||||
if not target or not isinstance(target, str):
|
if not target or not isinstance(target, str):
|
||||||
raise ValueError("Target must be a non-empty string")
|
raise ValueError("Target must be a non-empty string")
|
||||||
|
|
||||||
@@ -1184,13 +1203,8 @@ def assign_workspace_subdirs(targets_info: list[dict[str, Any]]) -> None:
|
|||||||
details["workspace_subdir"] = workspace_subdir
|
details["workspace_subdir"] = workspace_subdir
|
||||||
|
|
||||||
|
|
||||||
def is_whitebox_scan(targets_info: list[dict[str, Any]]) -> bool:
|
def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||||
"""True iff any target is a local source tree (whitebox / source-aware)."""
|
local_sources: list[dict[str, str]] = []
|
||||||
return any(t.get("type") == "local_code" for t in targets_info or [])
|
|
||||||
|
|
||||||
|
|
||||||
def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
local_sources: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for target_info in targets_info:
|
for target_info in targets_info:
|
||||||
details = target_info["details"]
|
details = target_info["details"]
|
||||||
@@ -1201,7 +1215,6 @@ def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str,
|
|||||||
{
|
{
|
||||||
"source_path": details["target_path"],
|
"source_path": details["target_path"],
|
||||||
"workspace_subdir": workspace_subdir,
|
"workspace_subdir": workspace_subdir,
|
||||||
"mount": bool(details.get("mount", False)),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1210,126 +1223,12 @@ def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str,
|
|||||||
{
|
{
|
||||||
"source_path": details["cloned_repo_path"],
|
"source_path": details["cloned_repo_path"],
|
||||||
"workspace_subdir": workspace_subdir,
|
"workspace_subdir": workspace_subdir,
|
||||||
"mount": False,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return local_sources
|
return local_sources
|
||||||
|
|
||||||
|
|
||||||
def directory_size_bytes(path: Path) -> int:
|
|
||||||
"""Total size in bytes of regular files under ``path`` (symlinks not followed).
|
|
||||||
|
|
||||||
Best-effort: files that disappear or can't be stat'd mid-walk are skipped.
|
|
||||||
Used as a cheap (stat-only) pre-flight to estimate the cost of streaming a
|
|
||||||
local target into the sandbox before we actually try to copy it.
|
|
||||||
|
|
||||||
Directories that can't be listed (e.g. permission denied) are logged and
|
|
||||||
skipped rather than silently dropped — so an under-count is at least
|
|
||||||
visible — but the returned total then excludes their contents.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _on_walk_error(error: OSError) -> None:
|
|
||||||
logger.warning("Could not read %s while measuring size: %s", error.filename, error)
|
|
||||||
|
|
||||||
total = 0
|
|
||||||
for root, _dirs, files in os.walk(path, followlinks=False, onerror=_on_walk_error):
|
|
||||||
for name in files:
|
|
||||||
file_path = os.path.join(root, name) # noqa: PTH118
|
|
||||||
try:
|
|
||||||
if os.path.islink(file_path): # noqa: PTH114
|
|
||||||
continue
|
|
||||||
total += os.path.getsize(file_path) # noqa: PTH202
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
return total
|
|
||||||
|
|
||||||
|
|
||||||
def find_oversized_local_targets(
|
|
||||||
targets_info: list[dict[str, Any]], max_bytes: int
|
|
||||||
) -> list[tuple[str, int]]:
|
|
||||||
"""Return ``(path, size_bytes)`` for non-mounted local targets over ``max_bytes``.
|
|
||||||
|
|
||||||
Mounted targets are bind-mounted rather than copied, so their size is
|
|
||||||
irrelevant and they are excluded. A ``max_bytes`` of zero or less disables
|
|
||||||
the check entirely (returns no targets).
|
|
||||||
"""
|
|
||||||
if max_bytes <= 0:
|
|
||||||
return []
|
|
||||||
oversized: list[tuple[str, int]] = []
|
|
||||||
for target in targets_info:
|
|
||||||
if target.get("type") != "local_code":
|
|
||||||
continue
|
|
||||||
details = target.get("details") or {}
|
|
||||||
if details.get("mount"):
|
|
||||||
continue
|
|
||||||
target_path = details.get("target_path")
|
|
||||||
if not target_path:
|
|
||||||
continue
|
|
||||||
size = directory_size_bytes(Path(target_path))
|
|
||||||
if size > max_bytes:
|
|
||||||
oversized.append((target_path, size))
|
|
||||||
return oversized
|
|
||||||
|
|
||||||
|
|
||||||
def build_mount_targets_info(mount_paths: list[str]) -> list[dict[str, Any]]:
|
|
||||||
"""Build ``targets_info`` entries for ``--mount`` directories.
|
|
||||||
|
|
||||||
Each path must be an existing local directory; it is bind-mounted into the
|
|
||||||
sandbox (read-only) instead of being copied file-by-file. Raises
|
|
||||||
``ValueError`` for an empty path, or one that does not exist or is not a
|
|
||||||
directory.
|
|
||||||
"""
|
|
||||||
targets_info: list[dict[str, Any]] = []
|
|
||||||
for raw in mount_paths:
|
|
||||||
if not raw or not raw.strip():
|
|
||||||
raise ValueError("--mount path must not be empty.")
|
|
||||||
path = Path(raw).expanduser()
|
|
||||||
try:
|
|
||||||
resolved = path.resolve()
|
|
||||||
is_dir = resolved.is_dir()
|
|
||||||
except (OSError, RuntimeError) as e:
|
|
||||||
raise ValueError(f"Invalid mount path '{raw}': {e!s}") from e
|
|
||||||
if not is_dir:
|
|
||||||
raise ValueError(
|
|
||||||
f"Mount path '{raw}' is not an existing directory. "
|
|
||||||
"--mount requires a path to a local directory."
|
|
||||||
)
|
|
||||||
targets_info.append(
|
|
||||||
{
|
|
||||||
"type": "local_code",
|
|
||||||
"details": {"target_path": str(resolved), "mount": True},
|
|
||||||
"original": str(resolved),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return targets_info
|
|
||||||
|
|
||||||
|
|
||||||
def dedupe_local_targets(targets_info: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
"""Collapse local_code targets that resolve to the same path.
|
|
||||||
|
|
||||||
When a directory is supplied both as a copied ``--target`` and via
|
|
||||||
``--mount`` (or as duplicate values of either), keep one entry and prefer
|
|
||||||
the bind-mounted one — so the same tree is never both streamed in and
|
|
||||||
mounted. Order is preserved; non-local targets pass through untouched.
|
|
||||||
"""
|
|
||||||
result: list[dict[str, Any]] = []
|
|
||||||
index_by_path: dict[str, int] = {}
|
|
||||||
for target in targets_info:
|
|
||||||
details = target.get("details") or {}
|
|
||||||
path = details.get("target_path")
|
|
||||||
if target.get("type") != "local_code" or not path:
|
|
||||||
result.append(target)
|
|
||||||
continue
|
|
||||||
existing = index_by_path.get(path)
|
|
||||||
if existing is None:
|
|
||||||
index_by_path[path] = len(result)
|
|
||||||
result.append(target)
|
|
||||||
elif details.get("mount") and not (result[existing].get("details") or {}).get("mount"):
|
|
||||||
result[existing] = target # bind mount supersedes the copied entry
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _is_localhost_host(host: str) -> bool:
|
def _is_localhost_host(host: str) -> bool:
|
||||||
host_lower = host.lower().strip("[]")
|
host_lower = host.lower().strip("[]")
|
||||||
|
|
||||||
@@ -1349,7 +1248,7 @@ def _is_localhost_host(host: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway: str) -> None:
|
def rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway: str) -> None:
|
||||||
from yarl import URL
|
from yarl import URL # type: ignore[import-not-found]
|
||||||
|
|
||||||
for target_info in targets_info:
|
for target_info in targets_info:
|
||||||
target_type = target_info.get("type")
|
target_type = target_info.get("type")
|
||||||
@@ -1371,6 +1270,7 @@ def rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway:
|
|||||||
details["target_ip"] = host_gateway
|
details["target_ip"] = host_gateway
|
||||||
|
|
||||||
|
|
||||||
|
# Repository utilities
|
||||||
def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str:
|
def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str:
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -1447,6 +1347,7 @@ def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None)
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Docker utilities
|
||||||
def check_docker_connection() -> Any:
|
def check_docker_connection() -> Any:
|
||||||
try:
|
try:
|
||||||
return docker.from_env()
|
return docker.from_env()
|
||||||
@@ -1522,6 +1423,12 @@ def process_pull_line(
|
|||||||
return last_update
|
return last_update
|
||||||
|
|
||||||
|
|
||||||
|
# LLM utilities
|
||||||
|
def validate_llm_response(response: Any) -> None:
|
||||||
|
if not response or not response.choices or not response.choices[0].message.content:
|
||||||
|
raise RuntimeError("Invalid response from LLM")
|
||||||
|
|
||||||
|
|
||||||
def validate_config_file(config_path: str) -> Path:
|
def validate_config_file(config_path: str) -> Path:
|
||||||
console = Console()
|
console = Console()
|
||||||
path = Path(config_path)
|
path = Path(config_path)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import logging
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
from .config import LLMConfig
|
||||||
|
from .llm import LLM, LLMRequestFailedError
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LLM",
|
||||||
|
"LLMConfig",
|
||||||
|
"LLMRequestFailedError",
|
||||||
|
]
|
||||||
|
|
||||||
|
litellm._logging._disable_debugging()
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
|
||||||
|
logging.getLogger("asyncio").propagate = False
|
||||||
|
warnings.filterwarnings("ignore", category=RuntimeWarning, module="asyncio")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from strix.config import Config
|
||||||
|
from strix.config.config import resolve_llm_config
|
||||||
|
from strix.llm.utils import resolve_strix_model
|
||||||
|
|
||||||
|
|
||||||
|
class LLMConfig:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model_name: str | None = None,
|
||||||
|
enable_prompt_caching: bool = True,
|
||||||
|
skills: list[str] | None = None,
|
||||||
|
timeout: int | None = None,
|
||||||
|
scan_mode: str = "deep",
|
||||||
|
is_whitebox: bool = False,
|
||||||
|
interactive: bool = False,
|
||||||
|
reasoning_effort: str | None = None,
|
||||||
|
system_prompt_context: dict[str, Any] | None = None,
|
||||||
|
):
|
||||||
|
resolved_model, self.api_key, self.api_base = resolve_llm_config()
|
||||||
|
self.model_name = model_name or resolved_model
|
||||||
|
|
||||||
|
if not self.model_name:
|
||||||
|
raise ValueError("STRIX_LLM environment variable must be set and not empty")
|
||||||
|
|
||||||
|
api_model, canonical = resolve_strix_model(self.model_name)
|
||||||
|
self.litellm_model: str = api_model or self.model_name
|
||||||
|
self.canonical_model: str = canonical or self.model_name
|
||||||
|
|
||||||
|
self.enable_prompt_caching = enable_prompt_caching
|
||||||
|
self.skills = skills or []
|
||||||
|
|
||||||
|
self.timeout = timeout or int(Config.get("llm_timeout") or "300")
|
||||||
|
|
||||||
|
self.scan_mode = scan_mode if scan_mode in ["quick", "standard", "deep"] else "deep"
|
||||||
|
self.is_whitebox = is_whitebox
|
||||||
|
self.interactive = interactive
|
||||||
|
self.reasoning_effort = reasoning_effort
|
||||||
|
self.system_prompt_context = system_prompt_context or {}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
from strix.config.config import resolve_llm_config
|
||||||
|
from strix.llm.utils import resolve_strix_model
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge.
|
||||||
|
Your task is to determine if a candidate vulnerability report describes the SAME vulnerability
|
||||||
|
as any existing report.
|
||||||
|
|
||||||
|
CRITICAL DEDUPLICATION RULES:
|
||||||
|
|
||||||
|
1. SAME VULNERABILITY means:
|
||||||
|
- Same root cause (e.g., "missing input validation" not just "SQL injection")
|
||||||
|
- Same affected component/endpoint/file (exact match or clear overlap)
|
||||||
|
- Same exploitation method or attack vector
|
||||||
|
- Would be fixed by the same code change/patch
|
||||||
|
|
||||||
|
2. NOT DUPLICATES if:
|
||||||
|
- Different endpoints even with same vulnerability type (e.g., SQLi in /login vs /search)
|
||||||
|
- Different parameters in same endpoint (e.g., XSS in 'name' vs 'comment' field)
|
||||||
|
- Different root causes (e.g., stored XSS vs reflected XSS in same field)
|
||||||
|
- Different severity levels due to different impact
|
||||||
|
- One is authenticated, other is unauthenticated
|
||||||
|
|
||||||
|
3. ARE DUPLICATES even if:
|
||||||
|
- Titles are worded differently
|
||||||
|
- Descriptions have different level of detail
|
||||||
|
- PoC uses different payloads but exploits same issue
|
||||||
|
- One report is more thorough than another
|
||||||
|
- Minor variations in technical analysis
|
||||||
|
|
||||||
|
COMPARISON GUIDELINES:
|
||||||
|
- Focus on the technical root cause, not surface-level similarities
|
||||||
|
- Same vulnerability type (SQLi, XSS) doesn't mean duplicate - location matters
|
||||||
|
- Consider the fix: would fixing one also fix the other?
|
||||||
|
- When uncertain, lean towards NOT duplicate
|
||||||
|
|
||||||
|
FIELDS TO ANALYZE:
|
||||||
|
- title, description: General vulnerability info
|
||||||
|
- target, endpoint, method: Exact location of vulnerability
|
||||||
|
- technical_analysis: Root cause details
|
||||||
|
- poc_description: How it's exploited
|
||||||
|
- impact: What damage it can cause
|
||||||
|
|
||||||
|
YOU MUST RESPOND WITH EXACTLY THIS XML FORMAT AND NOTHING ELSE:
|
||||||
|
|
||||||
|
<dedupe_result>
|
||||||
|
<is_duplicate>true</is_duplicate>
|
||||||
|
<duplicate_id>vuln-0001</duplicate_id>
|
||||||
|
<confidence>0.95</confidence>
|
||||||
|
<reason>Both reports describe SQL injection in /api/login via the username parameter</reason>
|
||||||
|
</dedupe_result>
|
||||||
|
|
||||||
|
OR if not a duplicate:
|
||||||
|
|
||||||
|
<dedupe_result>
|
||||||
|
<is_duplicate>false</is_duplicate>
|
||||||
|
<duplicate_id></duplicate_id>
|
||||||
|
<confidence>0.90</confidence>
|
||||||
|
<reason>Different endpoints: candidate is /api/search, existing is /api/login</reason>
|
||||||
|
</dedupe_result>
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- is_duplicate MUST be exactly "true" or "false" (lowercase)
|
||||||
|
- duplicate_id MUST be the exact ID from existing reports or empty if not duplicate
|
||||||
|
- confidence MUST be a decimal (your confidence level in the decision)
|
||||||
|
- reason MUST be a specific explanation mentioning endpoint/parameter/root cause
|
||||||
|
- DO NOT include any text outside the <dedupe_result> tags"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_report_for_comparison(report: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
relevant_fields = [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"impact",
|
||||||
|
"target",
|
||||||
|
"technical_analysis",
|
||||||
|
"poc_description",
|
||||||
|
"endpoint",
|
||||||
|
"method",
|
||||||
|
]
|
||||||
|
|
||||||
|
cleaned = {}
|
||||||
|
for field in relevant_fields:
|
||||||
|
if report.get(field):
|
||||||
|
value = report[field]
|
||||||
|
if isinstance(value, str) and len(value) > 8000:
|
||||||
|
value = value[:8000] + "...[truncated]"
|
||||||
|
cleaned[field] = value
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_xml_field(content: str, field: str) -> str:
|
||||||
|
pattern = rf"<{field}>(.*?)</{field}>"
|
||||||
|
match = re.search(pattern, content, re.DOTALL | re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dedupe_response(content: str) -> dict[str, Any]:
|
||||||
|
result_match = re.search(
|
||||||
|
r"<dedupe_result>(.*?)</dedupe_result>", content, re.DOTALL | re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result_match:
|
||||||
|
logger.warning(f"No <dedupe_result> block found in response: {content[:500]}")
|
||||||
|
raise ValueError("No <dedupe_result> block found in response")
|
||||||
|
|
||||||
|
result_content = result_match.group(1)
|
||||||
|
|
||||||
|
is_duplicate_str = _extract_xml_field(result_content, "is_duplicate")
|
||||||
|
duplicate_id = _extract_xml_field(result_content, "duplicate_id")
|
||||||
|
confidence_str = _extract_xml_field(result_content, "confidence")
|
||||||
|
reason = _extract_xml_field(result_content, "reason")
|
||||||
|
|
||||||
|
is_duplicate = is_duplicate_str.lower() == "true"
|
||||||
|
|
||||||
|
try:
|
||||||
|
confidence = float(confidence_str) if confidence_str else 0.0
|
||||||
|
except ValueError:
|
||||||
|
confidence = 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_duplicate": is_duplicate,
|
||||||
|
"duplicate_id": duplicate_id[:64] if duplicate_id else "",
|
||||||
|
"confidence": confidence,
|
||||||
|
"reason": reason[:500] if reason else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_duplicate(
|
||||||
|
candidate: dict[str, Any], existing_reports: list[dict[str, Any]]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not existing_reports:
|
||||||
|
return {
|
||||||
|
"is_duplicate": False,
|
||||||
|
"duplicate_id": "",
|
||||||
|
"confidence": 1.0,
|
||||||
|
"reason": "No existing reports to compare against",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
candidate_cleaned = _prepare_report_for_comparison(candidate)
|
||||||
|
existing_cleaned = [_prepare_report_for_comparison(r) for r in existing_reports]
|
||||||
|
|
||||||
|
comparison_data = {"candidate": candidate_cleaned, "existing_reports": existing_cleaned}
|
||||||
|
|
||||||
|
model_name, api_key, api_base = resolve_llm_config()
|
||||||
|
litellm_model, _ = resolve_strix_model(model_name)
|
||||||
|
litellm_model = litellm_model or model_name
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": DEDUPE_SYSTEM_PROMPT},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"Compare this candidate vulnerability against existing reports:\n\n"
|
||||||
|
f"{json.dumps(comparison_data, indent=2)}\n\n"
|
||||||
|
f"Respond with ONLY the <dedupe_result> XML block."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
completion_kwargs: dict[str, Any] = {
|
||||||
|
"model": litellm_model,
|
||||||
|
"messages": messages,
|
||||||
|
"timeout": 120,
|
||||||
|
}
|
||||||
|
if api_key:
|
||||||
|
completion_kwargs["api_key"] = api_key
|
||||||
|
if api_base:
|
||||||
|
completion_kwargs["api_base"] = api_base
|
||||||
|
|
||||||
|
response = litellm.completion(**completion_kwargs)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
if not content:
|
||||||
|
return {
|
||||||
|
"is_duplicate": False,
|
||||||
|
"duplicate_id": "",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"reason": "Empty response from LLM",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _parse_dedupe_response(content)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Deduplication check: is_duplicate={result['is_duplicate']}, "
|
||||||
|
f"confidence={result['confidence']}, reason={result['reason'][:100]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error during vulnerability deduplication check")
|
||||||
|
return {
|
||||||
|
"is_duplicate": False,
|
||||||
|
"duplicate_id": "",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"reason": f"Deduplication check failed: {e}",
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return result
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import asyncio
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import litellm
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
from litellm import acompletion, completion_cost, stream_chunk_builder, supports_reasoning
|
||||||
|
from litellm.utils import supports_prompt_caching, supports_vision
|
||||||
|
|
||||||
|
from strix.config import Config
|
||||||
|
from strix.llm.config import LLMConfig
|
||||||
|
from strix.llm.memory_compressor import MemoryCompressor
|
||||||
|
from strix.llm.utils import (
|
||||||
|
_truncate_to_first_function,
|
||||||
|
fix_incomplete_tool_call,
|
||||||
|
normalize_tool_format,
|
||||||
|
parse_tool_invocations,
|
||||||
|
)
|
||||||
|
from strix.skills import load_skills
|
||||||
|
from strix.tools import get_tools_prompt
|
||||||
|
from strix.utils.resource_paths import get_strix_resource_path
|
||||||
|
|
||||||
|
|
||||||
|
litellm.drop_params = True
|
||||||
|
litellm.modify_params = True
|
||||||
|
|
||||||
|
|
||||||
|
class LLMRequestFailedError(Exception):
|
||||||
|
def __init__(self, message: str, details: str | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.details = details
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LLMResponse:
|
||||||
|
content: str
|
||||||
|
tool_invocations: list[dict[str, Any]] | None = None
|
||||||
|
thinking_blocks: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RequestStats:
|
||||||
|
input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
cached_tokens: int = 0
|
||||||
|
cost: float = 0.0
|
||||||
|
requests: int = 0
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, int | float]:
|
||||||
|
return {
|
||||||
|
"input_tokens": self.input_tokens,
|
||||||
|
"output_tokens": self.output_tokens,
|
||||||
|
"cached_tokens": self.cached_tokens,
|
||||||
|
"cost": round(self.cost, 4),
|
||||||
|
"requests": self.requests,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LLM:
|
||||||
|
def __init__(self, config: LLMConfig, agent_name: str | None = None):
|
||||||
|
self.config = config
|
||||||
|
self.agent_name = agent_name
|
||||||
|
self.agent_id: str | None = None
|
||||||
|
self._active_skills: list[str] = list(config.skills or [])
|
||||||
|
self._system_prompt_context: dict[str, Any] = dict(
|
||||||
|
getattr(config, "system_prompt_context", {}) or {}
|
||||||
|
)
|
||||||
|
self._total_stats = RequestStats()
|
||||||
|
self.memory_compressor = MemoryCompressor(model_name=config.litellm_model)
|
||||||
|
self.system_prompt = self._load_system_prompt(agent_name)
|
||||||
|
|
||||||
|
reasoning = Config.get("strix_reasoning_effort")
|
||||||
|
if reasoning:
|
||||||
|
self._reasoning_effort = reasoning
|
||||||
|
elif config.reasoning_effort:
|
||||||
|
self._reasoning_effort = config.reasoning_effort
|
||||||
|
elif config.scan_mode == "quick":
|
||||||
|
self._reasoning_effort = "medium"
|
||||||
|
else:
|
||||||
|
self._reasoning_effort = "high"
|
||||||
|
|
||||||
|
def _load_system_prompt(self, agent_name: str | None) -> str:
|
||||||
|
if not agent_name:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
prompt_dir = get_strix_resource_path("agents", agent_name)
|
||||||
|
skills_dir = get_strix_resource_path("skills")
|
||||||
|
env = Environment(
|
||||||
|
loader=FileSystemLoader([prompt_dir, skills_dir]),
|
||||||
|
autoescape=select_autoescape(enabled_extensions=(), default_for_string=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
skills_to_load = self._get_skills_to_load()
|
||||||
|
skill_content = load_skills(skills_to_load)
|
||||||
|
env.globals["get_skill"] = lambda name: skill_content.get(name, "")
|
||||||
|
|
||||||
|
result = env.get_template("system_prompt.jinja").render(
|
||||||
|
get_tools_prompt=get_tools_prompt,
|
||||||
|
loaded_skill_names=list(skill_content.keys()),
|
||||||
|
interactive=self.config.interactive,
|
||||||
|
system_prompt_context=self._system_prompt_context,
|
||||||
|
**skill_content,
|
||||||
|
)
|
||||||
|
return str(result)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _get_skills_to_load(self) -> list[str]:
|
||||||
|
ordered_skills = [*self._active_skills]
|
||||||
|
ordered_skills.append(f"scan_modes/{self.config.scan_mode}")
|
||||||
|
if self.config.is_whitebox:
|
||||||
|
ordered_skills.append("coordination/source_aware_whitebox")
|
||||||
|
ordered_skills.append("custom/source_aware_sast")
|
||||||
|
|
||||||
|
deduped: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for skill_name in ordered_skills:
|
||||||
|
if skill_name not in seen:
|
||||||
|
deduped.append(skill_name)
|
||||||
|
seen.add(skill_name)
|
||||||
|
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
def add_skills(self, skill_names: list[str]) -> list[str]:
|
||||||
|
added: list[str] = []
|
||||||
|
for skill_name in skill_names:
|
||||||
|
if not skill_name or skill_name in self._active_skills:
|
||||||
|
continue
|
||||||
|
self._active_skills.append(skill_name)
|
||||||
|
added.append(skill_name)
|
||||||
|
|
||||||
|
if not added:
|
||||||
|
return []
|
||||||
|
|
||||||
|
updated_prompt = self._load_system_prompt(self.agent_name)
|
||||||
|
if updated_prompt:
|
||||||
|
self.system_prompt = updated_prompt
|
||||||
|
|
||||||
|
return added
|
||||||
|
|
||||||
|
def set_agent_identity(self, agent_name: str | None, agent_id: str | None) -> None:
|
||||||
|
if agent_name:
|
||||||
|
self.agent_name = agent_name
|
||||||
|
if agent_id:
|
||||||
|
self.agent_id = agent_id
|
||||||
|
|
||||||
|
def set_system_prompt_context(self, context: dict[str, Any] | None) -> None:
|
||||||
|
self._system_prompt_context = dict(context or {})
|
||||||
|
updated_prompt = self._load_system_prompt(self.agent_name)
|
||||||
|
if updated_prompt:
|
||||||
|
self.system_prompt = updated_prompt
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self, conversation_history: list[dict[str, Any]]
|
||||||
|
) -> AsyncIterator[LLMResponse]:
|
||||||
|
messages = self._prepare_messages(conversation_history)
|
||||||
|
max_retries = int(Config.get("strix_llm_max_retries") or "5")
|
||||||
|
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
async for response in self._stream(messages):
|
||||||
|
yield response
|
||||||
|
return # noqa: TRY300
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
if attempt >= max_retries or not self._should_retry(e):
|
||||||
|
self._raise_error(e)
|
||||||
|
wait = min(90, 2 * (2**attempt))
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
|
||||||
|
async def _stream(self, messages: list[dict[str, Any]]) -> AsyncIterator[LLMResponse]:
|
||||||
|
accumulated = ""
|
||||||
|
chunks: list[Any] = []
|
||||||
|
done_streaming = 0
|
||||||
|
|
||||||
|
self._total_stats.requests += 1
|
||||||
|
response = await acompletion(**self._build_completion_args(messages), stream=True)
|
||||||
|
|
||||||
|
async for chunk in response:
|
||||||
|
chunks.append(chunk)
|
||||||
|
if done_streaming:
|
||||||
|
done_streaming += 1
|
||||||
|
if getattr(chunk, "usage", None) or done_streaming > 5:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
delta = self._get_chunk_content(chunk)
|
||||||
|
if delta:
|
||||||
|
accumulated += delta
|
||||||
|
if "</function>" in accumulated or "</invoke>" in accumulated:
|
||||||
|
end_tag = "</function>" if "</function>" in accumulated else "</invoke>"
|
||||||
|
pos = accumulated.find(end_tag)
|
||||||
|
accumulated = accumulated[: pos + len(end_tag)]
|
||||||
|
yield LLMResponse(content=accumulated)
|
||||||
|
done_streaming = 1
|
||||||
|
continue
|
||||||
|
yield LLMResponse(content=accumulated)
|
||||||
|
|
||||||
|
if chunks:
|
||||||
|
self._update_usage_stats(stream_chunk_builder(chunks))
|
||||||
|
|
||||||
|
accumulated = normalize_tool_format(accumulated)
|
||||||
|
accumulated = fix_incomplete_tool_call(_truncate_to_first_function(accumulated))
|
||||||
|
yield LLMResponse(
|
||||||
|
content=accumulated,
|
||||||
|
tool_invocations=parse_tool_invocations(accumulated),
|
||||||
|
thinking_blocks=self._extract_thinking(chunks),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _prepare_messages(self, conversation_history: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
messages = [{"role": "system", "content": self.system_prompt}]
|
||||||
|
|
||||||
|
if self.agent_name:
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"\n\n<agent_identity>\n"
|
||||||
|
f"<meta>Internal metadata: do not echo or reference.</meta>\n"
|
||||||
|
f"<agent_name>{self.agent_name}</agent_name>\n"
|
||||||
|
f"<agent_id>{self.agent_id}</agent_id>\n"
|
||||||
|
f"</agent_identity>\n\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
compressed = list(self.memory_compressor.compress_history(conversation_history))
|
||||||
|
conversation_history.clear()
|
||||||
|
conversation_history.extend(compressed)
|
||||||
|
messages.extend(compressed)
|
||||||
|
|
||||||
|
if messages[-1].get("role") == "assistant" and not self.config.interactive:
|
||||||
|
messages.append({"role": "user", "content": "<meta>Continue the task.</meta>"})
|
||||||
|
|
||||||
|
if self._is_anthropic() and self.config.enable_prompt_caching:
|
||||||
|
messages = self._add_cache_control(messages)
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def _build_completion_args(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
if not self._supports_vision():
|
||||||
|
messages = self._strip_images(messages)
|
||||||
|
|
||||||
|
args: dict[str, Any] = {
|
||||||
|
"model": self.config.litellm_model,
|
||||||
|
"messages": messages,
|
||||||
|
"timeout": self.config.timeout,
|
||||||
|
"stream_options": {"include_usage": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config.api_key:
|
||||||
|
args["api_key"] = self.config.api_key
|
||||||
|
if self.config.api_base:
|
||||||
|
args["api_base"] = self.config.api_base
|
||||||
|
if self._supports_reasoning():
|
||||||
|
args["reasoning_effort"] = self._reasoning_effort
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
def _get_chunk_content(self, chunk: Any) -> str:
|
||||||
|
if chunk.choices and hasattr(chunk.choices[0], "delta"):
|
||||||
|
return getattr(chunk.choices[0].delta, "content", "") or ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _extract_thinking(self, chunks: list[Any]) -> list[dict[str, Any]] | None:
|
||||||
|
if not chunks or not self._supports_reasoning():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resp = stream_chunk_builder(chunks)
|
||||||
|
if resp.choices and hasattr(resp.choices[0].message, "thinking_blocks"):
|
||||||
|
blocks: list[dict[str, Any]] = resp.choices[0].message.thinking_blocks
|
||||||
|
return blocks
|
||||||
|
except Exception: # noqa: BLE001, S110 # nosec B110
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _update_usage_stats(self, response: Any) -> None:
|
||||||
|
try:
|
||||||
|
if hasattr(response, "usage") and response.usage:
|
||||||
|
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
|
||||||
|
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
|
||||||
|
|
||||||
|
cached_tokens = 0
|
||||||
|
if hasattr(response.usage, "prompt_tokens_details"):
|
||||||
|
prompt_details = response.usage.prompt_tokens_details
|
||||||
|
if hasattr(prompt_details, "cached_tokens"):
|
||||||
|
cached_tokens = prompt_details.cached_tokens or 0
|
||||||
|
|
||||||
|
cost = self._extract_cost(response)
|
||||||
|
else:
|
||||||
|
input_tokens = 0
|
||||||
|
output_tokens = 0
|
||||||
|
cached_tokens = 0
|
||||||
|
cost = 0.0
|
||||||
|
|
||||||
|
self._total_stats.input_tokens += input_tokens
|
||||||
|
self._total_stats.output_tokens += output_tokens
|
||||||
|
self._total_stats.cached_tokens += cached_tokens
|
||||||
|
self._total_stats.cost += cost
|
||||||
|
|
||||||
|
except Exception: # noqa: BLE001, S110 # nosec B110
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _extract_cost(self, response: Any) -> float:
|
||||||
|
if hasattr(response, "usage") and response.usage:
|
||||||
|
direct_cost = getattr(response.usage, "cost", None)
|
||||||
|
if direct_cost is not None:
|
||||||
|
return float(direct_cost)
|
||||||
|
try:
|
||||||
|
if hasattr(response, "_hidden_params"):
|
||||||
|
response._hidden_params.pop("custom_llm_provider", None)
|
||||||
|
return completion_cost(response, model=self.config.canonical_model) or 0.0
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _should_retry(self, e: Exception) -> bool:
|
||||||
|
code = getattr(e, "status_code", None) or getattr(
|
||||||
|
getattr(e, "response", None), "status_code", None
|
||||||
|
)
|
||||||
|
return code is None or litellm._should_retry(code)
|
||||||
|
|
||||||
|
def _raise_error(self, e: Exception) -> None:
|
||||||
|
from strix.telemetry import posthog
|
||||||
|
|
||||||
|
posthog.error("llm_error", type(e).__name__)
|
||||||
|
raise LLMRequestFailedError(f"LLM request failed: {type(e).__name__}", str(e)) from e
|
||||||
|
|
||||||
|
def _is_anthropic(self) -> bool:
|
||||||
|
if not self.config.model_name:
|
||||||
|
return False
|
||||||
|
return any(p in self.config.model_name.lower() for p in ["anthropic/", "claude"])
|
||||||
|
|
||||||
|
def _supports_vision(self) -> bool:
|
||||||
|
try:
|
||||||
|
return bool(supports_vision(model=self.config.canonical_model))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _supports_reasoning(self) -> bool:
|
||||||
|
try:
|
||||||
|
return bool(supports_reasoning(model=self.config.canonical_model))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _strip_images(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
result = []
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
text_parts = []
|
||||||
|
for item in content:
|
||||||
|
if isinstance(item, dict) and item.get("type") == "text":
|
||||||
|
text_parts.append(item.get("text", ""))
|
||||||
|
elif isinstance(item, dict) and item.get("type") == "image_url":
|
||||||
|
text_parts.append("[Image removed - model doesn't support vision]")
|
||||||
|
result.append({**msg, "content": "\n".join(text_parts)})
|
||||||
|
else:
|
||||||
|
result.append(msg)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _add_cache_control(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
if not messages or not supports_prompt_caching(self.config.canonical_model):
|
||||||
|
return messages
|
||||||
|
|
||||||
|
result = list(messages)
|
||||||
|
|
||||||
|
if result[0].get("role") == "system":
|
||||||
|
content = result[0]["content"]
|
||||||
|
result[0] = {
|
||||||
|
**result[0],
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}
|
||||||
|
]
|
||||||
|
if isinstance(content, str)
|
||||||
|
else content,
|
||||||
|
}
|
||||||
|
return result
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user