crow-cli 0.1.32__tar.gz → 0.1.34__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {crow_cli-0.1.32 → crow_cli-0.1.34}/PKG-INFO +7 -5
- {crow_cli-0.1.32 → crow_cli-0.1.34}/README.md +3 -2
- {crow_cli-0.1.32 → crow_cli-0.1.34}/crow-cli.spec +1 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/pyproject.toml +7 -3
- crow_cli-0.1.34/run_tests.sh +4 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/compact.py +7 -6
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/configure.py +114 -14
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/default/defaults.py +21 -6
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/main.py +51 -8
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/memory.py +25 -17
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/model_routing.py +15 -13
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/react.py +149 -105
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/session.py +3 -3
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/tools.py +0 -503
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/cli/init_cmd.py +6 -6
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/cli/main.py +27 -8
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/conftest.py +4 -3
- crow_cli-0.1.34/tests/e2e/test_react_live.py +79 -0
- crow_cli-0.1.34/tests/integration/test_acp_http.py +75 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/integration/test_auth_validation.py +1 -1
- crow_cli-0.1.34/tests/integration/test_react_loop_cancel_integrity.py +320 -0
- crow_cli-0.1.34/tests/integration/test_react_loop_tool_round.py +297 -0
- crow_cli-0.1.34/tests/integration/test_reasoning_effort_react_loop.py +97 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_compact.py +37 -1
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_configure.py +17 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_model_routing.py +23 -31
- crow_cli-0.1.34/tests/unit/test_react.py +432 -0
- crow_cli-0.1.34/tests/unit/test_reasoning_effort.py +277 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_session.py +4 -3
- {crow_cli-0.1.32 → crow_cli-0.1.34}/uv.lock +103 -5
- crow_cli-0.1.32/run_tests.sh +0 -6
- crow_cli-0.1.32/src/crow_cli/agent/db.py +0 -393
- crow_cli-0.1.32/test_send_request_error_handling.py +0 -195
- crow_cli-0.1.32/tests/unit/test_db.py +0 -100
- {crow_cli-0.1.32 → crow_cli-0.1.34}/.gitignore +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/.python-version +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/agent.json +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/examples/mc_escher_loop.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/examples/quick_test.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/__init__.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/__init__.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/context.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/default/__init__.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/default/searxng_settings.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/hooks.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/llm.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/logger.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/mcp_client.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/prompt.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent/slash.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/agent_runner.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/cli/__init__.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/cli/install.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/client/__init__.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/client/main.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/src/crow_cli/client/terminal.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/__init__.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/analyze_payload_cache_invalidation.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/analyze_payload_deep.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/e2e/test_session_update_transmission.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/integration/test_agent_init.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_client_terminal.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_compact_last_messages_list_content.json +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_compact_last_messages_list_content.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_json_repair.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_model_override.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_request_logging.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_stream_processing.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_system_prompt_skills.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_usage_update.py +0 -0
- {crow_cli-0.1.32 → crow_cli-0.1.34}/tests/unit/test_uv_project_enforcement.py +0 -0
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: crow-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.34
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Project-URL: Homepage, https://github.com/crow-cli/crow-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/crow-cli/crow-cli
|
|
7
7
|
Project-URL: Issues, https://github.com/crow-cli/crow-cli/issues
|
|
8
8
|
Project-URL: Documentation, https://github.com/crow-cli/crow-cli#readme
|
|
9
9
|
Requires-Python: >=3.14
|
|
10
|
-
Requires-Dist: agent-client-protocol<0.13.0,>=0.12.0
|
|
10
|
+
Requires-Dist: agent-client-protocol[http]<0.13.0,>=0.12.0
|
|
11
11
|
Requires-Dist: coolname>=2.2.0
|
|
12
|
+
Requires-Dist: crow-memory==0.1.34
|
|
12
13
|
Requires-Dist: directory-tree>=1.0.0
|
|
13
14
|
Requires-Dist: docker>=7.2.0
|
|
14
15
|
Requires-Dist: fastmcp>=2.14.5
|
|
15
16
|
Requires-Dist: httpx>=0.28.1
|
|
17
|
+
Requires-Dist: hypercorn>=0.17
|
|
16
18
|
Requires-Dist: jinja2>=3.1.6
|
|
17
19
|
Requires-Dist: openai>=2.21.0
|
|
18
20
|
Requires-Dist: pyyaml>=6.0.3
|
|
19
21
|
Requires-Dist: rich>=14.3.3
|
|
20
|
-
Requires-Dist: sqlalchemy>=2.0.51
|
|
21
22
|
Requires-Dist: typer>=0.24.1
|
|
22
23
|
Description-Content-Type: text/markdown
|
|
23
24
|
|
|
@@ -171,13 +172,13 @@ cd crow-cli
|
|
|
171
172
|
uv sync --project crow-cli
|
|
172
173
|
```
|
|
173
174
|
|
|
174
|
-
Run the unit tests — fast and hermetic, no services required (tests that touch sessions use an in-memory fake of the
|
|
175
|
+
Run the unit tests — fast and hermetic, no services required (unit tests that touch sessions use an in-memory fake of the persistence client; the integration tier uses a real throwaway sqlite db):
|
|
175
176
|
|
|
176
177
|
```bash
|
|
177
178
|
uv run --project crow-cli pytest crow-cli/tests/unit
|
|
178
179
|
```
|
|
179
180
|
|
|
180
|
-
The persistence layer itself is tested in `crow-
|
|
181
|
+
The persistence layer itself lives in the `crow-memory` package and is tested in `crow-memory/tests/test_store.py`. Integration and end-to-end tiers are opt-in:
|
|
181
182
|
|
|
182
183
|
```bash
|
|
183
184
|
uv run --project crow-cli pytest crow-cli/tests --run-integration # spawn the agent
|
|
@@ -189,6 +190,7 @@ uv run --project crow-cli pytest crow-cli/tests --run-e2e # live LLM c
|
|
|
189
190
|
```
|
|
190
191
|
crow-cli/ the agent — ACP server, ReAct loop, CLI
|
|
191
192
|
crow-mcp/ built-in MCP tool server
|
|
193
|
+
crow-memory/ shared SQL persistence (sqlite default, postgres-ready)
|
|
192
194
|
```
|
|
193
195
|
|
|
194
196
|
## License
|
|
@@ -148,13 +148,13 @@ cd crow-cli
|
|
|
148
148
|
uv sync --project crow-cli
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
-
Run the unit tests — fast and hermetic, no services required (tests that touch sessions use an in-memory fake of the
|
|
151
|
+
Run the unit tests — fast and hermetic, no services required (unit tests that touch sessions use an in-memory fake of the persistence client; the integration tier uses a real throwaway sqlite db):
|
|
152
152
|
|
|
153
153
|
```bash
|
|
154
154
|
uv run --project crow-cli pytest crow-cli/tests/unit
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
The persistence layer itself is tested in `crow-
|
|
157
|
+
The persistence layer itself lives in the `crow-memory` package and is tested in `crow-memory/tests/test_store.py`. Integration and end-to-end tiers are opt-in:
|
|
158
158
|
|
|
159
159
|
```bash
|
|
160
160
|
uv run --project crow-cli pytest crow-cli/tests --run-integration # spawn the agent
|
|
@@ -166,6 +166,7 @@ uv run --project crow-cli pytest crow-cli/tests --run-e2e # live LLM c
|
|
|
166
166
|
```
|
|
167
167
|
crow-cli/ the agent — ACP server, ReAct loop, CLI
|
|
168
168
|
crow-mcp/ built-in MCP tool server
|
|
169
|
+
crow-memory/ shared SQL persistence (sqlite default, postgres-ready)
|
|
169
170
|
```
|
|
170
171
|
|
|
171
172
|
## License
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "crow-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.34"
|
|
4
4
|
description = "Add your description here"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
7
7
|
dependencies = [
|
|
8
|
-
"agent-client-protocol>=0.12.0,<0.13.0",
|
|
8
|
+
"agent-client-protocol[http]>=0.12.0,<0.13.0",
|
|
9
|
+
"crow-memory==0.1.34",
|
|
10
|
+
"hypercorn>=0.17",
|
|
9
11
|
"coolname>=2.2.0",
|
|
10
12
|
"directory-tree>=1.0.0",
|
|
11
13
|
"docker>=7.2.0",
|
|
@@ -15,10 +17,12 @@ dependencies = [
|
|
|
15
17
|
"openai>=2.21.0",
|
|
16
18
|
"pyyaml>=6.0.3",
|
|
17
19
|
"rich>=14.3.3",
|
|
18
|
-
"sqlalchemy>=2.0.51",
|
|
19
20
|
"typer>=0.24.1",
|
|
20
21
|
]
|
|
21
22
|
|
|
23
|
+
[tool.uv.sources]
|
|
24
|
+
crow-memory = { path = "../crow-memory", editable = true }
|
|
25
|
+
|
|
22
26
|
|
|
23
27
|
[project.urls]
|
|
24
28
|
Homepage = "https://github.com/crow-cli/crow-cli"
|
|
@@ -17,7 +17,7 @@ from logging import Logger
|
|
|
17
17
|
|
|
18
18
|
from openai import AsyncOpenAI
|
|
19
19
|
|
|
20
|
-
from crow_cli.agent.configure import Config
|
|
20
|
+
from crow_cli.agent.configure import Config, sampling_params_for
|
|
21
21
|
from crow_cli.agent.session import (
|
|
22
22
|
AgentSession,
|
|
23
23
|
make_agent_session,
|
|
@@ -123,16 +123,17 @@ async def compact(
|
|
|
123
123
|
# 3. Append compaction prompt
|
|
124
124
|
messages.append({"role": "user", "content": COMPACTION_PROMPT})
|
|
125
125
|
|
|
126
|
-
# 4. Send to LLM
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
# 4. Send to LLM — same per-model sampling rule as the react loop: the
|
|
127
|
+
# model's reasoning_effort XOR temperature. Never the provider default
|
|
128
|
+
# (temp 1.0 makes the model ramble instead of compress) and never the
|
|
129
|
+
# session's request_params temperature.
|
|
130
130
|
response = await llm.chat.completions.create(
|
|
131
131
|
model=session.model_identifier,
|
|
132
132
|
messages=messages,
|
|
133
133
|
tools=session.tools if session.tools else None,
|
|
134
134
|
tool_choice="none",
|
|
135
|
-
|
|
135
|
+
max_tokens=MAX_OUTPUT_TOKENS,
|
|
136
|
+
**sampling_params_for(config, session.model_identifier),
|
|
136
137
|
)
|
|
137
138
|
summary = response.choices[0].message.content
|
|
138
139
|
usage = {
|
|
@@ -10,10 +10,12 @@ import os
|
|
|
10
10
|
import re
|
|
11
11
|
from dataclasses import dataclass, field
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import Any
|
|
13
|
+
from typing import Any, Literal
|
|
14
14
|
|
|
15
15
|
import yaml
|
|
16
|
+
from crow_memory import normalize_db_uri
|
|
16
17
|
from dotenv import load_dotenv
|
|
18
|
+
from pydantic import BaseModel, ValidationError
|
|
17
19
|
|
|
18
20
|
from crow_cli.agent.default import (
|
|
19
21
|
COMPOSE_YAML,
|
|
@@ -88,6 +90,69 @@ def resolve_env_vars(value: Any, missing: set[str] | None = None) -> Any:
|
|
|
88
90
|
return value
|
|
89
91
|
|
|
90
92
|
|
|
93
|
+
# OpenAI reasoning models (gpt-5, o3, ...) accept `reasoning_effort` instead
|
|
94
|
+
# of `temperature`. These are the enumerable values from the OpenAI API
|
|
95
|
+
# reference (developers.openai.com): none, minimal, low, medium, high, xhigh,
|
|
96
|
+
# max. We validate config.yaml against this set so a typo fails fast at load
|
|
97
|
+
# time instead of as an opaque provider 400 mid-turn.
|
|
98
|
+
ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
|
99
|
+
REASONING_EFFORT_VALUES = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
|
|
100
|
+
|
|
101
|
+
# Per-model modality LIST — what the model can take as input. Default
|
|
102
|
+
# ["text", "image"] = assume vision-capable until proven otherwise; a
|
|
103
|
+
# text-only model gets ["text"]. audio/video are filled in by hand (or by a
|
|
104
|
+
# probe) for models that natively take them (qwen3.x-max takes video).
|
|
105
|
+
Modality = Literal["text", "image", "audio", "video"]
|
|
106
|
+
MODALITY_VALUES = ("text", "image", "audio", "video")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ReasoningEffortModel(BaseModel):
|
|
110
|
+
"""Validate a reasoning_effort value against OpenAI's enumerable set."""
|
|
111
|
+
|
|
112
|
+
reasoning_effort: ReasoningEffort
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_reasoning_effort(raw: Any) -> str:
|
|
116
|
+
"""Validate and normalize a config.yaml reasoning_effort value.
|
|
117
|
+
|
|
118
|
+
Raises ValueError naming the valid set when the value is not enumerable.
|
|
119
|
+
"""
|
|
120
|
+
value = str(raw).strip().lower()
|
|
121
|
+
try:
|
|
122
|
+
return ReasoningEffortModel(reasoning_effort=value).reasoning_effort
|
|
123
|
+
except ValidationError:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"reasoning_effort must be one of {', '.join(REASONING_EFFORT_VALUES)}, "
|
|
126
|
+
f"got {raw!r}"
|
|
127
|
+
) from None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_sampling_params(
|
|
131
|
+
reasoning_effort: str | None,
|
|
132
|
+
temperature: float,
|
|
133
|
+
) -> dict[str, Any]:
|
|
134
|
+
"""The one sampling rule for every LLM call (react loop AND compaction):
|
|
135
|
+
reasoning_effort when set — temperature omitted, reasoning models reject
|
|
136
|
+
it — else temperature. Never both, never neither (no provider defaults)."""
|
|
137
|
+
return (
|
|
138
|
+
{"reasoning_effort": reasoning_effort}
|
|
139
|
+
if reasoning_effort
|
|
140
|
+
else {"temperature": temperature}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def sampling_params_for(config: "Config", model_id: str) -> dict[str, Any]:
|
|
145
|
+
"""Per-model sampling rule for every LLM call (react loop AND compaction):
|
|
146
|
+
the configured model's reasoning_effort XOR temperature. Models not in
|
|
147
|
+
config get the defaults (temperature 0.6, no effort)."""
|
|
148
|
+
model = next(
|
|
149
|
+
(m for m in config.llm.models.values() if m.model_id == model_id), None
|
|
150
|
+
)
|
|
151
|
+
if model is None:
|
|
152
|
+
return {"temperature": 0.6}
|
|
153
|
+
return build_sampling_params(model.reasoning_effort, model.temperature)
|
|
154
|
+
|
|
155
|
+
|
|
91
156
|
@dataclass
|
|
92
157
|
class LLMProvider:
|
|
93
158
|
name: str
|
|
@@ -100,9 +165,14 @@ class LLModel:
|
|
|
100
165
|
name: str
|
|
101
166
|
provider_name: str
|
|
102
167
|
model_id: str
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
|
|
168
|
+
# Per-model sampling, XOR: reasoning_effort wins and temperature is
|
|
169
|
+
# omitted entirely (reasoning models reject it); else temperature.
|
|
170
|
+
temperature: float = 0.6
|
|
171
|
+
reasoning_effort: ReasoningEffort | None = None
|
|
172
|
+
# Input modalities. Assume vision-capable until proven otherwise;
|
|
173
|
+
# ["text"] models get image/audio/video blocks stripped / routed around
|
|
174
|
+
# (see model_routing).
|
|
175
|
+
modality: list[Modality] = field(default_factory=lambda: ["text", "image"])
|
|
106
176
|
# Ordered fallback chain (model NAMES from this config) used when this
|
|
107
177
|
# model cannot handle the modalities present in the conversation.
|
|
108
178
|
fallbacks: list[str] = field(default_factory=list)
|
|
@@ -118,13 +188,12 @@ class LLMConfig:
|
|
|
118
188
|
class Config:
|
|
119
189
|
config_dir: Path
|
|
120
190
|
llm: LLMConfig = field(default_factory=LLMConfig)
|
|
121
|
-
|
|
191
|
+
db_uri: str = ""
|
|
122
192
|
skills_dir: str = str(SKILLS_DIR)
|
|
123
193
|
mcp_servers: dict[str, Any] = field(default_factory=dict)
|
|
124
194
|
max_retries_per_step: int = 3
|
|
125
195
|
MAX_COMPACT_TOKENS: int = 190000
|
|
126
196
|
MAX_TOKENS: int = 38192
|
|
127
|
-
TEMPERATURE: float = 0.6
|
|
128
197
|
chunk_log: bool = False # Write every raw chunk to JSONL for debugging
|
|
129
198
|
system_prompt: str = SYSTEM_PROMPT
|
|
130
199
|
system_prompt_path: Path | None = None
|
|
@@ -164,12 +233,21 @@ class Config:
|
|
|
164
233
|
_logger.info("No config.yaml found, returning bare Config")
|
|
165
234
|
return cls(
|
|
166
235
|
config_dir=target_dir,
|
|
167
|
-
|
|
236
|
+
db_uri=f"sqlite:///{DEFAULT_CONFIG_DIR / 'crow.db'}",
|
|
168
237
|
)
|
|
169
238
|
|
|
170
239
|
with open(config_file) as f:
|
|
171
240
|
raw = yaml.safe_load(f) or {}
|
|
172
241
|
|
|
242
|
+
# Sampling is per-model now (see LLModel) — a leftover global would be
|
|
243
|
+
# silently ignored, so reject it with a migration hint instead.
|
|
244
|
+
for stale in ("TEMPERATURE", "reasoning_effort"):
|
|
245
|
+
if stale in raw:
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"config.yaml: global {stale!r} was removed; set it per "
|
|
248
|
+
f"model under models: (temperature / reasoning_effort)."
|
|
249
|
+
)
|
|
250
|
+
|
|
173
251
|
_logger.info("RAW config.yaml mcpServers: %s", raw.get("mcpServers", {}))
|
|
174
252
|
missing_vars: set[str] = set()
|
|
175
253
|
parsed = resolve_env_vars(raw, missing_vars)
|
|
@@ -191,25 +269,47 @@ class Config:
|
|
|
191
269
|
|
|
192
270
|
# Parse models
|
|
193
271
|
for name, data in parsed.get("models", {}).items():
|
|
194
|
-
|
|
195
|
-
|
|
272
|
+
raw_effort = data.get("reasoning_effort")
|
|
273
|
+
raw_modality = data.get("modality", ["text", "image"])
|
|
274
|
+
if isinstance(raw_modality, str):
|
|
275
|
+
raw_modality = [raw_modality]
|
|
276
|
+
if not isinstance(raw_modality, list):
|
|
277
|
+
raise ValueError(
|
|
278
|
+
f"model {name!r}: modality must be a list, got {raw_modality!r}"
|
|
279
|
+
)
|
|
280
|
+
modality = [str(m).strip().lower() for m in raw_modality]
|
|
281
|
+
bad = [m for m in modality if m not in MODALITY_VALUES]
|
|
282
|
+
if bad:
|
|
283
|
+
raise ValueError(
|
|
284
|
+
f"model {name!r}: modality entries must be one of "
|
|
285
|
+
f"{', '.join(MODALITY_VALUES)}, got {bad}"
|
|
286
|
+
)
|
|
196
287
|
llm.models[name] = LLModel(
|
|
197
288
|
name=name,
|
|
198
289
|
provider_name=data.get("provider", ""),
|
|
199
290
|
model_id=data.get("model", ""),
|
|
200
|
-
|
|
291
|
+
temperature=float(data.get("temperature", 0.6)),
|
|
292
|
+
reasoning_effort=(
|
|
293
|
+
parse_reasoning_effort(raw_effort)
|
|
294
|
+
if raw_effort is not None
|
|
295
|
+
else None
|
|
296
|
+
),
|
|
297
|
+
modality=modality,
|
|
201
298
|
fallbacks=list(data.get("fallbacks") or []),
|
|
202
299
|
)
|
|
203
300
|
|
|
204
|
-
# Parse overrides
|
|
205
|
-
|
|
301
|
+
# Parse overrides. db_uri is the canonical key; legacy memory_path
|
|
302
|
+
# (a plain path) still works and becomes a sqlite URI. Default is
|
|
303
|
+
# config_dir-relative so custom config dirs stay self-contained.
|
|
304
|
+
db_uri = normalize_db_uri(
|
|
305
|
+
parsed.get("db_uri") or parsed.get("memory_path") or str(target_dir / "crow.db")
|
|
306
|
+
)
|
|
206
307
|
skills_dir = os.path.expanduser(parsed.get("skills_dir") or str(SKILLS_DIR))
|
|
207
308
|
overrides = {}
|
|
208
309
|
for key, typ in (
|
|
209
310
|
("max_retries_per_step", int),
|
|
210
311
|
("MAX_COMPACT_TOKENS", int),
|
|
211
312
|
("MAX_TOKENS", int),
|
|
212
|
-
("TEMPERATURE", float),
|
|
213
313
|
):
|
|
214
314
|
if key in parsed:
|
|
215
315
|
overrides[key] = typ(parsed[key])
|
|
@@ -225,7 +325,7 @@ class Config:
|
|
|
225
325
|
return cls(
|
|
226
326
|
config_dir=target_dir,
|
|
227
327
|
llm=llm,
|
|
228
|
-
|
|
328
|
+
db_uri=db_uri,
|
|
229
329
|
skills_dir=skills_dir,
|
|
230
330
|
mcp_servers=mcp_servers,
|
|
231
331
|
system_prompt_path=system_prompt_path,
|
|
@@ -209,18 +209,20 @@ volumes:
|
|
|
209
209
|
driver: local
|
|
210
210
|
"""
|
|
211
211
|
|
|
212
|
-
CONFIG_YAML = """# config.yaml
|
|
213
|
-
#
|
|
212
|
+
CONFIG_YAML = """# config.yaml — crow-cli configuration
|
|
213
|
+
# (loaded by crow_cli/agent/configure.py)
|
|
214
214
|
|
|
215
215
|
# DEFAULT mcpServers
|
|
216
216
|
mcpServers:
|
|
217
|
-
crow-mcp
|
|
217
|
+
crow-mcp:
|
|
218
218
|
transport: http
|
|
219
219
|
url: http://127.0.0.1:2770/mcp
|
|
220
220
|
|
|
221
|
-
# Memory is
|
|
222
|
-
#
|
|
223
|
-
|
|
221
|
+
# Memory is a SQL database reached via a SQLAlchemy db_uri — sqlite by
|
|
222
|
+
# default, any SQLAlchemy URI (e.g. postgresql://) works. Owned by the
|
|
223
|
+
# crow-memory package. Images are stored as files next to it (images/) and
|
|
224
|
+
# hydrated to base64 only when sent to the LLM.
|
|
225
|
+
db_uri: sqlite:///~/.agents/crow/crow.db
|
|
224
226
|
|
|
225
227
|
# Where agent skills live (one directory per skill, each with a SKILL.md).
|
|
226
228
|
# Scanned at session creation and injected into the system prompt.
|
|
@@ -243,6 +245,19 @@ system_prompt_path: ~/.agents/crow/prompts/system_prompt.jinja2
|
|
|
243
245
|
# placeholder-model:
|
|
244
246
|
# provider: placeholder-provider
|
|
245
247
|
# model: gpt-3.5-turbo
|
|
248
|
+
# temperature: 0.6
|
|
249
|
+
# # reasoning_effort: medium
|
|
250
|
+
# modality: [text, image]
|
|
251
|
+
|
|
252
|
+
# Per-model sampling (all optional):
|
|
253
|
+
# temperature — sampling temperature, default 0.6
|
|
254
|
+
# reasoning_effort — for reasoning models (gpt-5, o3, ...). When set, crow
|
|
255
|
+
# sends it and OMITS temperature (reasoning models reject
|
|
256
|
+
# temperature). One of: none | minimal | low | medium |
|
|
257
|
+
# high | xhigh | max
|
|
258
|
+
# modality — list of input modalities (text | image | audio | video).
|
|
259
|
+
# Default [text, image] = assume vision-capable until
|
|
260
|
+
# proven otherwise; [text] = strip/route images around.
|
|
246
261
|
|
|
247
262
|
# Compaction parameters
|
|
248
263
|
MAX_COMPACT_TOKENS: 180000
|
|
@@ -183,7 +183,7 @@ class AcpAgent(Agent):
|
|
|
183
183
|
raise ValueError(
|
|
184
184
|
f"model {model!r} not found in config.yaml models: {valid}"
|
|
185
185
|
)
|
|
186
|
-
self.
|
|
186
|
+
self._memory_db_uri = self._config.db_uri
|
|
187
187
|
self._exit_stack = AsyncExitStack()
|
|
188
188
|
self._agent_id: str | None = None
|
|
189
189
|
self._session_id: str | None = None # stripped version for ACP upstream
|
|
@@ -438,14 +438,14 @@ class AcpAgent(Agent):
|
|
|
438
438
|
|
|
439
439
|
try:
|
|
440
440
|
# Find the highest-indexed agent for this session
|
|
441
|
-
max_idx = await AgentSession.get_max_agent_idx(session_id, memory_path=self.
|
|
441
|
+
max_idx = await AgentSession.get_max_agent_idx(session_id, memory_path=self._memory_db_uri)
|
|
442
442
|
agent_id = f"{session_id}-{max_idx}"
|
|
443
443
|
self._logger.info(
|
|
444
444
|
"LOAD_SESSION: Step 1: Loading agent %s from DB", agent_id
|
|
445
445
|
)
|
|
446
446
|
session = await AgentSession.load(
|
|
447
447
|
agent_id,
|
|
448
|
-
memory_path=self.
|
|
448
|
+
memory_path=self._memory_db_uri,
|
|
449
449
|
)
|
|
450
450
|
self._logger.info("LOAD_SESSION: Step 1 complete: Agent loaded from DB")
|
|
451
451
|
|
|
@@ -553,7 +553,7 @@ class AcpAgent(Agent):
|
|
|
553
553
|
if self._session_id == session_id and self._agent_id:
|
|
554
554
|
agent_id = self._agent_id
|
|
555
555
|
else:
|
|
556
|
-
max_idx = await AgentSession.get_max_agent_idx(session_id, memory_path=self.
|
|
556
|
+
max_idx = await AgentSession.get_max_agent_idx(session_id, memory_path=self._memory_db_uri)
|
|
557
557
|
agent_id = f"{session_id}-{max_idx}"
|
|
558
558
|
|
|
559
559
|
# Initialize if not set
|
|
@@ -605,7 +605,7 @@ class AcpAgent(Agent):
|
|
|
605
605
|
if self._session_id == session_id and self._agent_id:
|
|
606
606
|
agent_id = self._agent_id
|
|
607
607
|
else:
|
|
608
|
-
max_idx = await AgentSession.get_max_agent_idx(session_id, memory_path=self.
|
|
608
|
+
max_idx = await AgentSession.get_max_agent_idx(session_id, memory_path=self._memory_db_uri)
|
|
609
609
|
agent_id = f"{session_id}-{max_idx}"
|
|
610
610
|
|
|
611
611
|
async def _execute_turn() -> PromptResponse:
|
|
@@ -839,21 +839,64 @@ class AcpAgent(Agent):
|
|
|
839
839
|
return ListSessionsResponse(sessions=sessions, next_cursor=None)
|
|
840
840
|
|
|
841
841
|
|
|
842
|
+
async def serve_http(
|
|
843
|
+
config: Config, model: str | None, host: str, port: int
|
|
844
|
+
) -> None:
|
|
845
|
+
"""Serve the agent over Streamable HTTP + WebSocket (experimental, same
|
|
846
|
+
JSON-RPC lifecycle as stdio). One AcpAgent instance per connection; all
|
|
847
|
+
instances share the sqlite session store, so any client can address the
|
|
848
|
+
same sessions."""
|
|
849
|
+
import hypercorn.asyncio
|
|
850
|
+
from acp.http.asgi import create_asgi_app
|
|
851
|
+
from hypercorn.config import Config as HypercornConfig
|
|
852
|
+
|
|
853
|
+
app = create_asgi_app(lambda conn: AcpAgent(config=config, model=model))
|
|
854
|
+
hcfg = HypercornConfig()
|
|
855
|
+
hcfg.bind = [f"{host}:{port}"]
|
|
856
|
+
# Streamable HTTP requires HTTP/2; hypercorn negotiates h2c/h2.
|
|
857
|
+
hcfg.alpn_protocols = ["h2", "http/1.1"]
|
|
858
|
+
await hypercorn.asyncio.serve(app, hcfg)
|
|
859
|
+
|
|
860
|
+
|
|
842
861
|
async def agent_run(
|
|
843
862
|
config_dir: Path | None = None,
|
|
844
863
|
config: Config | None = None,
|
|
845
864
|
debug: bool = False,
|
|
846
865
|
model: str | None = None,
|
|
866
|
+
http: bool = False,
|
|
867
|
+
host: str = "127.0.0.1",
|
|
868
|
+
port: int = 2769,
|
|
847
869
|
) -> None:
|
|
848
870
|
if config is None:
|
|
849
871
|
config = Config.load(config_dir=config_dir)
|
|
850
872
|
if debug:
|
|
851
873
|
config.chunk_log = True
|
|
852
|
-
|
|
874
|
+
if http:
|
|
875
|
+
await serve_http(config, model, host, port)
|
|
876
|
+
else:
|
|
877
|
+
await run_agent(AcpAgent(config=config, model=model))
|
|
853
878
|
|
|
854
879
|
|
|
855
|
-
def main(
|
|
856
|
-
|
|
880
|
+
def main(
|
|
881
|
+
config_dir: Path | None = None,
|
|
882
|
+
config: Config | None = None,
|
|
883
|
+
debug: bool = False,
|
|
884
|
+
model: str | None = None,
|
|
885
|
+
http: bool = False,
|
|
886
|
+
host: str = "127.0.0.1",
|
|
887
|
+
port: int = 2769,
|
|
888
|
+
):
|
|
889
|
+
asyncio.run(
|
|
890
|
+
agent_run(
|
|
891
|
+
config_dir=config_dir,
|
|
892
|
+
config=config,
|
|
893
|
+
debug=debug,
|
|
894
|
+
model=model,
|
|
895
|
+
http=http,
|
|
896
|
+
host=host,
|
|
897
|
+
port=port,
|
|
898
|
+
)
|
|
899
|
+
)
|
|
857
900
|
|
|
858
901
|
|
|
859
902
|
if __name__ == "__main__":
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
"""Memory layer for crow-cli —
|
|
1
|
+
"""Memory layer for crow-cli — SQL via crow-memory, images on disk.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
The store contract lives in the ``crow-memory`` package (schema v4): this
|
|
4
|
+
class only resolves the db_uri from config and wraps the sync helpers in the
|
|
5
|
+
async shape session.py / main.py expect. One wrinkle: image blobs never
|
|
6
|
+
enter the database. Writes extract inline base64 blocks to
|
|
7
|
+
``<db parent>/images/<sha256hex><ext>`` and store ``image_ref`` blocks;
|
|
8
|
+
loads with ``hydrate=True`` swap refs back to base64 data URLs for the LLM.
|
|
7
9
|
|
|
8
10
|
Search is SQLite FTS5 + bm25 (keyword). No embeddings, no HTTP service.
|
|
9
11
|
|
|
@@ -13,11 +15,10 @@ sync db helpers.
|
|
|
13
15
|
"""
|
|
14
16
|
|
|
15
17
|
import logging
|
|
16
|
-
import os
|
|
17
18
|
from dataclasses import dataclass, field
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
import crow_memory as db
|
|
21
22
|
from crow_cli.agent.configure import Config
|
|
22
23
|
|
|
23
24
|
log = logging.getLogger(__name__)
|
|
@@ -125,19 +126,26 @@ def _agent_record(a: db.Agent) -> AgentRecord:
|
|
|
125
126
|
|
|
126
127
|
|
|
127
128
|
class MemoryClient:
|
|
128
|
-
"""
|
|
129
|
+
"""crow-memory-backed store. `path` overrides the configured db_uri."""
|
|
129
130
|
|
|
130
131
|
def __init__(self, path: str | None = None, config_dir: Path | None = None, **_kwargs):
|
|
131
132
|
cfg = Config.load(config_dir)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
|
|
133
|
+
# DEFAULT_MEMORY_PATH is the legacy positional sentinel — it means
|
|
134
|
+
# "whatever the config says", not a literal override.
|
|
135
|
+
override = None if path in (None, DEFAULT_MEMORY_PATH) else path
|
|
136
|
+
self.db_uri = db.normalize_db_uri(override or cfg.db_uri)
|
|
137
|
+
if self.db_uri.startswith("sqlite:///"):
|
|
138
|
+
db_path = Path(self.db_uri.removeprefix("sqlite:///"))
|
|
139
|
+
if db_path.is_dir():
|
|
140
|
+
raise MemoryServiceError(
|
|
141
|
+
500,
|
|
142
|
+
f"{db_path} is a directory (leftover lance dataset from the old "
|
|
143
|
+
"crow-memory service) — remove it so sqlite can own this path",
|
|
144
|
+
)
|
|
145
|
+
self.images_dir = db_path.parent / "images"
|
|
146
|
+
else:
|
|
147
|
+
# Non-file backends (e.g. postgres): images stay beside the config.
|
|
148
|
+
self.images_dir = cfg.config_dir / "images"
|
|
141
149
|
db.create_database(self.db_uri)
|
|
142
150
|
self._engine = db.get_engine(self.db_uri)
|
|
143
151
|
|
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
Conversations carry modalities (images, audio) that not every model accepts.
|
|
4
4
|
Per request:
|
|
5
5
|
|
|
6
|
-
1. Requested model's
|
|
7
|
-
|
|
6
|
+
1. Requested model's modality list covers the present modalities (the
|
|
7
|
+
default ["text", "image"] assumes vision-capable until proven otherwise)
|
|
8
|
+
or no images are present → use it unchanged.
|
|
8
9
|
2. Otherwise walk the model's `fallbacks` chain and take the first model
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
whose list covers them. The LLM client is bound to one provider, so only
|
|
11
|
+
same-provider fallbacks are considered.
|
|
11
12
|
3. No capable model anywhere → keep the requested model and report which
|
|
12
13
|
modalities to strip; send_request replaces those blocks with placeholders
|
|
13
14
|
(auto-strip on downgrade) instead of hard-failing.
|
|
15
|
+
|
|
16
|
+
Only image blocks are routed for now; audio/video are not mapped.
|
|
14
17
|
"""
|
|
15
18
|
|
|
16
19
|
from __future__ import annotations
|
|
@@ -22,17 +25,15 @@ from crow_cli.agent.configure import Config
|
|
|
22
25
|
|
|
23
26
|
logger = logging.getLogger(__name__)
|
|
24
27
|
|
|
25
|
-
# OpenAI chat-completions content block type -> modality
|
|
28
|
+
# OpenAI chat-completions content block type -> modality (image only for now;
|
|
29
|
+
# audio/video blocks are not routed yet)
|
|
26
30
|
BLOCK_MODALITIES: dict[str, str] = {
|
|
27
|
-
"image_url": "
|
|
28
|
-
"image": "
|
|
29
|
-
"input_audio": "audio",
|
|
30
|
-
"audio": "audio",
|
|
31
|
+
"image_url": "image",
|
|
32
|
+
"image": "image",
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
PLACEHOLDERS: dict[str, str] = {
|
|
34
|
-
"
|
|
35
|
-
"audio": "[audio omitted: model has no audio capability]",
|
|
36
|
+
"image": "[image omitted: model has no image capability]",
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
|
|
@@ -58,8 +59,9 @@ def _model_by_id(config: Config, model_id: str):
|
|
|
58
59
|
|
|
59
60
|
|
|
60
61
|
def _capable(model, modalities: set[str]) -> bool:
|
|
61
|
-
#
|
|
62
|
-
|
|
62
|
+
# Default ["text", "image"] = permissive: assume vision-capable until
|
|
63
|
+
# proven otherwise; ["text"] models fail the subset check for images.
|
|
64
|
+
return modalities <= set(model.modality)
|
|
63
65
|
|
|
64
66
|
|
|
65
67
|
def route_model(
|