mycode-cli 0.14.2__py3-none-any.whl → 0.16.0__py3-none-any.whl
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.
- mycode_cli/config.py +36 -30
- mycode_cli/main.py +25 -11
- mycode_cli/permissions.py +4 -7
- mycode_cli/runtime.py +3 -13
- mycode_cli/server/app.py +18 -3
- mycode_cli/server/deps.py +8 -12
- mycode_cli/server/routers/chat.py +11 -10
- mycode_cli/server/routers/sessions.py +13 -15
- mycode_cli/server/routers/settings.py +10 -8
- mycode_cli/server/routers/workspaces.py +2 -2
- mycode_cli/server/run_manager.py +91 -49
- mycode_cli/server/static/assets/{index-DzNDonIR.js → index-CT9f_Z06.js} +2 -2
- mycode_cli/server/static/index.html +1 -1
- mycode_cli/sessions.py +32 -2
- mycode_cli/system_prompt.py +5 -4
- mycode_cli/tools.py +23 -21
- mycode_cli/tui/chat.py +21 -41
- mycode_cli/tui/render.py +39 -33
- {mycode_cli-0.14.2.dist-info → mycode_cli-0.16.0.dist-info}/METADATA +2 -2
- {mycode_cli-0.14.2.dist-info → mycode_cli-0.16.0.dist-info}/RECORD +23 -23
- {mycode_cli-0.14.2.dist-info → mycode_cli-0.16.0.dist-info}/WHEEL +0 -0
- {mycode_cli-0.14.2.dist-info → mycode_cli-0.16.0.dist-info}/entry_points.txt +0 -0
- {mycode_cli-0.14.2.dist-info → mycode_cli-0.16.0.dist-info}/licenses/LICENSE +0 -0
mycode_cli/config.py
CHANGED
|
@@ -71,6 +71,9 @@ class ProviderConfig:
|
|
|
71
71
|
# Opt-in for generic openai_chat endpoints that accept the standard
|
|
72
72
|
# top-level reasoning_effort param. Ignored for other provider types.
|
|
73
73
|
supports_reasoning_effort: bool = False
|
|
74
|
+
# Opt-in for generic openai_chat endpoints that only implement the legacy
|
|
75
|
+
# max_tokens field. Ignored for other provider types.
|
|
76
|
+
legacy_max_tokens: bool = False
|
|
74
77
|
|
|
75
78
|
|
|
76
79
|
@dataclass(frozen=True)
|
|
@@ -119,6 +122,7 @@ class ResolvedProvider:
|
|
|
119
122
|
provider_name: str | None = None
|
|
120
123
|
model_config: ModelConfig | None = None
|
|
121
124
|
supports_reasoning_effort: bool = False
|
|
125
|
+
legacy_max_tokens: bool = False
|
|
122
126
|
reasoning_efforts: tuple[str, ...] = ()
|
|
123
127
|
|
|
124
128
|
|
|
@@ -187,7 +191,7 @@ def parse_compact_threshold(value: Any) -> float | None:
|
|
|
187
191
|
return None
|
|
188
192
|
threshold = float(value)
|
|
189
193
|
|
|
190
|
-
if
|
|
194
|
+
if not 0 <= threshold <= 1:
|
|
191
195
|
return None
|
|
192
196
|
return threshold
|
|
193
197
|
|
|
@@ -434,6 +438,12 @@ def _validate_provider_config(name: str, raw: Any) -> dict[str, Any]:
|
|
|
434
438
|
raise ValueError(f"provider {name!r}: supports_reasoning_effort must be a boolean")
|
|
435
439
|
out["supports_reasoning_effort"] = supports_effort
|
|
436
440
|
|
|
441
|
+
legacy_max_tokens = raw.get("legacy_max_tokens")
|
|
442
|
+
if legacy_max_tokens is not None:
|
|
443
|
+
if not isinstance(legacy_max_tokens, bool):
|
|
444
|
+
raise ValueError(f"provider {name!r}: legacy_max_tokens must be a boolean")
|
|
445
|
+
out["legacy_max_tokens"] = legacy_max_tokens
|
|
446
|
+
|
|
437
447
|
raw_models = raw.get("models")
|
|
438
448
|
if raw_models is not None:
|
|
439
449
|
models = _validate_model_config_entries(name, raw_models)
|
|
@@ -504,45 +514,39 @@ def get_settings(cwd: str | None = None) -> Settings:
|
|
|
504
514
|
if resolved_path not in config_paths:
|
|
505
515
|
config_paths.append(resolved_path)
|
|
506
516
|
|
|
507
|
-
for name, raw in
|
|
508
|
-
if not isinstance(name, str) or not isinstance(raw, dict):
|
|
509
|
-
continue
|
|
510
|
-
|
|
517
|
+
for name, raw in data.get("providers", {}).items():
|
|
511
518
|
merged = dict(raw_providers.get(name, {}))
|
|
512
519
|
|
|
513
520
|
if "type" in raw:
|
|
514
|
-
merged["type"] = raw
|
|
521
|
+
merged["type"] = raw["type"]
|
|
515
522
|
if "models" in raw:
|
|
516
|
-
merged["models"] = raw
|
|
523
|
+
merged["models"] = raw["models"]
|
|
517
524
|
if "api_key" in raw:
|
|
518
|
-
api_key, api_key_env_var = _parse_config_api_key(raw
|
|
525
|
+
api_key, api_key_env_var = _parse_config_api_key(raw["api_key"])
|
|
519
526
|
merged["api_key"] = api_key
|
|
520
527
|
merged["api_key_env_var"] = api_key_env_var
|
|
521
528
|
if "base_url" in raw:
|
|
522
|
-
merged["base_url"] = raw
|
|
529
|
+
merged["base_url"] = raw["base_url"]
|
|
523
530
|
if "supports_reasoning_effort" in raw:
|
|
524
|
-
merged["supports_reasoning_effort"] = raw
|
|
531
|
+
merged["supports_reasoning_effort"] = raw["supports_reasoning_effort"]
|
|
532
|
+
if "legacy_max_tokens" in raw:
|
|
533
|
+
merged["legacy_max_tokens"] = raw["legacy_max_tokens"]
|
|
525
534
|
|
|
526
535
|
raw_providers[name] = merged
|
|
527
536
|
|
|
528
|
-
default = data.get("default")
|
|
529
|
-
if
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
default_model = v if isinstance(v, str) else None
|
|
536
|
-
if "compact_threshold" in default:
|
|
537
|
-
parsed_threshold = parse_compact_threshold(default.get("compact_threshold"))
|
|
538
|
-
if parsed_threshold is not None:
|
|
539
|
-
compact_threshold = parsed_threshold
|
|
537
|
+
default = data.get("default", {})
|
|
538
|
+
if "provider" in default:
|
|
539
|
+
default_provider = default["provider"]
|
|
540
|
+
if "model" in default:
|
|
541
|
+
default_model = default["model"]
|
|
542
|
+
if "compact_threshold" in default:
|
|
543
|
+
compact_threshold = float(default["compact_threshold"])
|
|
540
544
|
|
|
541
545
|
if "permission" in data:
|
|
542
546
|
permission = parse_permission(data.get("permission"), permission)
|
|
543
547
|
|
|
544
|
-
if
|
|
545
|
-
web =
|
|
548
|
+
if "web" in data:
|
|
549
|
+
web = data["web"]
|
|
546
550
|
for key in ("fetch", "search"):
|
|
547
551
|
if key in web:
|
|
548
552
|
raw_web[key] = web[key]
|
|
@@ -631,6 +635,7 @@ def _build_providers(raw_providers: dict[str, dict[str, Any]]) -> dict[str, Prov
|
|
|
631
635
|
api_key_env_var=raw.get("api_key_env_var") or None,
|
|
632
636
|
base_url=raw.get("base_url") or None,
|
|
633
637
|
supports_reasoning_effort=raw.get("supports_reasoning_effort") is True,
|
|
638
|
+
legacy_max_tokens=raw.get("legacy_max_tokens") is True,
|
|
634
639
|
)
|
|
635
640
|
|
|
636
641
|
return providers
|
|
@@ -768,14 +773,12 @@ def _available_provider_references(settings: Settings) -> list[str]:
|
|
|
768
773
|
|
|
769
774
|
add(settings.default_provider)
|
|
770
775
|
|
|
771
|
-
for name
|
|
772
|
-
|
|
773
|
-
add(name)
|
|
776
|
+
for name in settings.providers:
|
|
777
|
+
add(name)
|
|
774
778
|
|
|
775
779
|
for provider_id in list_env_discoverable_providers():
|
|
776
|
-
if provider_id in configured_types_with_credentials
|
|
777
|
-
|
|
778
|
-
add(provider_id)
|
|
780
|
+
if provider_id not in configured_types_with_credentials:
|
|
781
|
+
add(provider_id)
|
|
779
782
|
|
|
780
783
|
return available
|
|
781
784
|
|
|
@@ -825,6 +828,8 @@ def _resolve_provider_runtime(
|
|
|
825
828
|
supports_effort = adapter.supports_reasoning_effort or bool(
|
|
826
829
|
provider_config and provider_config.supports_reasoning_effort and isinstance(adapter, OpenAIChatAdapter)
|
|
827
830
|
)
|
|
831
|
+
# The legacy max_tokens opt-in only applies to generic openai_chat providers.
|
|
832
|
+
legacy_max_tokens = bool(provider_config and provider_config.legacy_max_tokens and provider_type == "openai_chat")
|
|
828
833
|
resolved_api_key = api_key
|
|
829
834
|
if not resolved_api_key and provider_config:
|
|
830
835
|
if env_name := provider_config.api_key_env_var:
|
|
@@ -851,5 +856,6 @@ def _resolve_provider_runtime(
|
|
|
851
856
|
reasoning_effort=None,
|
|
852
857
|
model_config=model_config,
|
|
853
858
|
supports_reasoning_effort=supports_effort,
|
|
859
|
+
legacy_max_tokens=legacy_max_tokens,
|
|
854
860
|
reasoning_efforts=reasoning_efforts,
|
|
855
861
|
)
|
mycode_cli/main.py
CHANGED
|
@@ -5,9 +5,9 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
-
from contextlib import suppress
|
|
8
|
+
from contextlib import aclosing, suppress
|
|
9
9
|
from dataclasses import dataclass, replace
|
|
10
|
-
from typing import Annotated, Any, Literal
|
|
10
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
|
11
11
|
from uuid import uuid4
|
|
12
12
|
|
|
13
13
|
import typer
|
|
@@ -28,8 +28,9 @@ from mycode_cli.permissions import PERMISSION_DENIED_BY_USER_OUTPUT, PERMISSION_
|
|
|
28
28
|
from mycode_cli.sessions import SessionStore
|
|
29
29
|
|
|
30
30
|
from .runtime import build_agent
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from .tui.render import TerminalView
|
|
33
34
|
|
|
34
35
|
app = typer.Typer(add_completion=False, pretty_exceptions_enable=False)
|
|
35
36
|
session_app = typer.Typer(help="Session management")
|
|
@@ -106,13 +107,14 @@ async def run_noninteractive(agent: Agent, message: str, *, store: SessionStore,
|
|
|
106
107
|
latest_assistant = payload
|
|
107
108
|
|
|
108
109
|
error_message = ""
|
|
109
|
-
async
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
110
|
+
async with aclosing(agent.achat(message, on_persist=track)) as stream:
|
|
111
|
+
async for event in stream:
|
|
112
|
+
if event.type == "error":
|
|
113
|
+
error_message = str(event.data.get("message") or "agent error")
|
|
114
|
+
elif event.type == "tool_done" and event.data.get("is_error"):
|
|
115
|
+
output = str(event.data.get("output") or "")
|
|
116
|
+
if output in {PERMISSION_DENIED_OUTPUT, PERMISSION_DENIED_BY_USER_OUTPUT}:
|
|
117
|
+
error_message = output
|
|
116
118
|
|
|
117
119
|
reply = ""
|
|
118
120
|
if latest_assistant:
|
|
@@ -157,6 +159,8 @@ def _bootstrap(
|
|
|
157
159
|
) -> _BootstrapContext:
|
|
158
160
|
"""Shared setup for the chat and run commands."""
|
|
159
161
|
|
|
162
|
+
from .tui.render import TerminalView
|
|
163
|
+
|
|
160
164
|
if session and continue_last:
|
|
161
165
|
raise typer.BadParameter("--session and --continue are mutually exclusive")
|
|
162
166
|
|
|
@@ -244,6 +248,8 @@ def chat(
|
|
|
244
248
|
if ctx.invoked_subcommand is not None:
|
|
245
249
|
return
|
|
246
250
|
|
|
251
|
+
from .tui.chat import TerminalChat
|
|
252
|
+
|
|
247
253
|
setup = _bootstrap(
|
|
248
254
|
provider=provider,
|
|
249
255
|
model=model,
|
|
@@ -323,6 +329,7 @@ def web(
|
|
|
323
329
|
resolved_port = port or settings.port
|
|
324
330
|
|
|
325
331
|
import uvicorn
|
|
332
|
+
from uvicorn.config import LOGGING_CONFIG
|
|
326
333
|
|
|
327
334
|
app_factory = "mycode_cli.server.app:create_web_app"
|
|
328
335
|
if dev:
|
|
@@ -334,6 +341,11 @@ def web(
|
|
|
334
341
|
port=resolved_port,
|
|
335
342
|
reload=dev,
|
|
336
343
|
factory=True,
|
|
344
|
+
timeout_graceful_shutdown=5,
|
|
345
|
+
log_config={
|
|
346
|
+
**LOGGING_CONFIG,
|
|
347
|
+
"root": {"handlers": ["default"], "level": "INFO"},
|
|
348
|
+
},
|
|
337
349
|
)
|
|
338
350
|
|
|
339
351
|
|
|
@@ -343,6 +355,8 @@ def session_list(
|
|
|
343
355
|
) -> None:
|
|
344
356
|
"""List saved sessions."""
|
|
345
357
|
|
|
358
|
+
from .tui.render import TerminalView
|
|
359
|
+
|
|
346
360
|
cwd = os.path.abspath(os.getcwd())
|
|
347
361
|
store = SessionStore(data_dir=resolve_sessions_dir())
|
|
348
362
|
view = TerminalView()
|
mycode_cli/permissions.py
CHANGED
|
@@ -13,7 +13,6 @@ from mycode_cli.config import PermissionConfig, PermissionLevel, Settings
|
|
|
13
13
|
from mycode_cli.system_prompt import discover_skills
|
|
14
14
|
from mycode_cli.workspace import CliDeps, resolve_path
|
|
15
15
|
|
|
16
|
-
PermissionTier = Literal["readonly", "safe", "standard", "yolo"]
|
|
17
16
|
PermissionDecision = Literal["allow", "ask", "deny"]
|
|
18
17
|
ToolReviewDecision = Literal["allow", "deny"]
|
|
19
18
|
|
|
@@ -54,7 +53,7 @@ _FIND_DANGEROUS_FLAGS = {
|
|
|
54
53
|
|
|
55
54
|
|
|
56
55
|
class PermissionCheck(NamedTuple):
|
|
57
|
-
tier:
|
|
56
|
+
tier: PermissionLevel
|
|
58
57
|
preview: str
|
|
59
58
|
|
|
60
59
|
|
|
@@ -102,10 +101,8 @@ def build_permission_hooks(
|
|
|
102
101
|
return hooks
|
|
103
102
|
|
|
104
103
|
|
|
105
|
-
def permission_decision(permission: PermissionConfig, tier:
|
|
106
|
-
if permission.level
|
|
107
|
-
return "allow"
|
|
108
|
-
if tier != "yolo" and _LEVEL_RANK[tier] <= _LEVEL_RANK[permission.level]:
|
|
104
|
+
def permission_decision(permission: PermissionConfig, tier: PermissionLevel) -> PermissionDecision:
|
|
105
|
+
if _LEVEL_RANK[tier] <= _LEVEL_RANK[permission.level]:
|
|
109
106
|
return "allow"
|
|
110
107
|
return permission.mode
|
|
111
108
|
|
|
@@ -147,7 +144,7 @@ def classify_tool(
|
|
|
147
144
|
return PermissionCheck("yolo", ctx.tool_name)
|
|
148
145
|
|
|
149
146
|
|
|
150
|
-
def _classify_bash(command: str) ->
|
|
147
|
+
def _classify_bash(command: str) -> PermissionLevel:
|
|
151
148
|
if not command:
|
|
152
149
|
return "yolo"
|
|
153
150
|
# Newlines and shell expansion bypass static analysis; treat as compound.
|
mycode_cli/runtime.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from mycode.agent import Agent
|
|
6
6
|
from mycode_cli.config import ResolvedProvider, Settings
|
|
7
7
|
from mycode_cli.permissions import ToolReviewCallback, build_permission_hooks
|
|
8
|
-
from mycode_cli.sessions import SessionStore
|
|
8
|
+
from mycode_cli.sessions import SessionStore, sum_session_cost
|
|
9
9
|
from mycode_cli.system_prompt import build_system_prompt
|
|
10
10
|
from mycode_cli.tools import DEFAULT_TOOLS
|
|
11
11
|
from mycode_cli.web_tools import build_web_tools
|
|
@@ -24,18 +24,7 @@ async def load_session_cost(store: SessionStore, session_id: str) -> float | Non
|
|
|
24
24
|
discarded by rewind. Records without a cost are skipped.
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
for message in await store.load_raw_messages(session_id):
|
|
29
|
-
if message.get("role") not in {"assistant", "compact"}:
|
|
30
|
-
continue
|
|
31
|
-
meta = message.get("meta") or {}
|
|
32
|
-
cost = meta.get("cost")
|
|
33
|
-
if not isinstance(cost, dict):
|
|
34
|
-
continue
|
|
35
|
-
request_total = cost.get("total")
|
|
36
|
-
if isinstance(request_total, int | float):
|
|
37
|
-
total = sum_known_costs(total, float(request_total))
|
|
38
|
-
return total
|
|
27
|
+
return sum_session_cost(await store.load_raw_messages(session_id))
|
|
39
28
|
|
|
40
29
|
|
|
41
30
|
def build_agent(
|
|
@@ -65,6 +54,7 @@ def build_agent(
|
|
|
65
54
|
api_base=resolved_provider.api_base,
|
|
66
55
|
reasoning_effort=resolved_provider.reasoning_effort,
|
|
67
56
|
supports_reasoning_effort=resolved_provider.supports_reasoning_effort,
|
|
57
|
+
legacy_max_tokens=resolved_provider.legacy_max_tokens,
|
|
68
58
|
max_tokens=model_config.max_output_tokens if model_config else None,
|
|
69
59
|
context_window=model_config.context_window if model_config else None,
|
|
70
60
|
supports_image_input=model_config.supports_image_input if model_config else None,
|
mycode_cli/server/app.py
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
"""FastAPI application entry point."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import logging
|
|
4
|
-
from collections.abc import Sequence
|
|
5
|
+
from collections.abc import AsyncGenerator, Sequence
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
7
9
|
from fastapi import FastAPI
|
|
8
10
|
from fastapi.middleware.cors import CORSMiddleware
|
|
9
11
|
from fastapi.staticfiles import StaticFiles
|
|
10
12
|
|
|
13
|
+
from mycode_cli.config import resolve_sessions_dir
|
|
11
14
|
from mycode_cli.server.routers.chat import router as chat_router
|
|
12
15
|
from mycode_cli.server.routers.sessions import router as sessions_router
|
|
13
16
|
from mycode_cli.server.routers.settings import router as settings_router
|
|
14
17
|
from mycode_cli.server.routers.workspaces import router as workspaces_router
|
|
18
|
+
from mycode_cli.server.run_manager import RunManager
|
|
19
|
+
from mycode_cli.sessions import SessionStore
|
|
15
20
|
|
|
16
21
|
logger = logging.getLogger(__name__)
|
|
17
|
-
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
18
22
|
DEV_CORS_ORIGINS = ("http://localhost:5173", "http://127.0.0.1:5173")
|
|
19
23
|
|
|
20
24
|
|
|
@@ -24,9 +28,20 @@ def web_static_path() -> Path:
|
|
|
24
28
|
return Path(__file__).resolve().parent / "static"
|
|
25
29
|
|
|
26
30
|
|
|
31
|
+
@asynccontextmanager
|
|
32
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
|
33
|
+
app.state.store = await asyncio.to_thread(SessionStore, data_dir=resolve_sessions_dir())
|
|
34
|
+
runs = RunManager()
|
|
35
|
+
app.state.runs = runs
|
|
36
|
+
try:
|
|
37
|
+
yield
|
|
38
|
+
finally:
|
|
39
|
+
await runs.aclose()
|
|
40
|
+
|
|
41
|
+
|
|
27
42
|
def create_app(*, serve_web: bool = True, cors_origins: Sequence[str] = ()) -> FastAPI:
|
|
28
43
|
"""Create the FastAPI app."""
|
|
29
|
-
application = FastAPI(title="mycode")
|
|
44
|
+
application = FastAPI(title="mycode", lifespan=lifespan)
|
|
30
45
|
|
|
31
46
|
if cors_origins:
|
|
32
47
|
application.add_middleware(
|
mycode_cli/server/deps.py
CHANGED
|
@@ -3,12 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
-
from
|
|
7
|
-
from typing import Annotated
|
|
6
|
+
from typing import Annotated, cast
|
|
8
7
|
|
|
9
|
-
from fastapi import Depends, HTTPException
|
|
8
|
+
from fastapi import Depends, HTTPException, Request
|
|
10
9
|
|
|
11
|
-
from mycode_cli.config import resolve_sessions_dir
|
|
12
10
|
from mycode_cli.server.run_manager import RunManager
|
|
13
11
|
from mycode_cli.sessions import SessionStore
|
|
14
12
|
|
|
@@ -22,18 +20,16 @@ def resolve_workspace_cwd(raw: str | None) -> str:
|
|
|
22
20
|
return cwd
|
|
23
21
|
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"""Return the shared session store for server requests."""
|
|
23
|
+
async def get_store(request: Request) -> SessionStore:
|
|
24
|
+
"""Return this application's session store."""
|
|
28
25
|
|
|
29
|
-
return SessionStore
|
|
26
|
+
return cast(SessionStore, request.app.state.store)
|
|
30
27
|
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"""Return the shared in-process run manager."""
|
|
29
|
+
async def get_run_manager(request: Request) -> RunManager:
|
|
30
|
+
"""Return this application's run manager."""
|
|
35
31
|
|
|
36
|
-
return RunManager
|
|
32
|
+
return cast(RunManager, request.app.state.runs)
|
|
37
33
|
|
|
38
34
|
|
|
39
35
|
StoreDep = Annotated[SessionStore, Depends(get_store)]
|
|
@@ -63,7 +63,7 @@ router = APIRouter()
|
|
|
63
63
|
def _resolve_workspace_attachment_path(rel_path: str, *, cwd: str) -> Path:
|
|
64
64
|
base = resolve_path(".", cwd=cwd)
|
|
65
65
|
path = resolve_path(rel_path, cwd=cwd)
|
|
66
|
-
if
|
|
66
|
+
if not path.is_relative_to(base):
|
|
67
67
|
raise HTTPException(status_code=400, detail=f"path outside workspace: {rel_path}")
|
|
68
68
|
return path
|
|
69
69
|
|
|
@@ -97,7 +97,9 @@ async def _build_user_message(chat: ChatRequest, cwd: str) -> ConversationMessag
|
|
|
97
97
|
for block in chat.input:
|
|
98
98
|
if block.type == "text":
|
|
99
99
|
if block.path:
|
|
100
|
-
blocks.extend(
|
|
100
|
+
blocks.extend(
|
|
101
|
+
await asyncio.to_thread(_read_workspace_text_attachment, block.path, name=block.name, cwd=cwd)
|
|
102
|
+
)
|
|
101
103
|
continue
|
|
102
104
|
text = block.text or ""
|
|
103
105
|
if block.is_attachment:
|
|
@@ -145,11 +147,8 @@ def _validate_rewind_request(
|
|
|
145
147
|
*,
|
|
146
148
|
session: dict[str, Any] | None,
|
|
147
149
|
messages: list[ConversationMessage],
|
|
148
|
-
rewind_to: int
|
|
150
|
+
rewind_to: int,
|
|
149
151
|
) -> None:
|
|
150
|
-
if rewind_to is None:
|
|
151
|
-
return
|
|
152
|
-
|
|
153
152
|
if session is None:
|
|
154
153
|
raise HTTPException(status_code=400, detail="rewind_to requires an existing session")
|
|
155
154
|
|
|
@@ -236,10 +235,11 @@ async def chat(chat: ChatRequest, store: StoreDep, runs: RunManagerDep) -> ChatR
|
|
|
236
235
|
detail={"message": "session already has a running task", "run": active},
|
|
237
236
|
)
|
|
238
237
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
238
|
+
if chat.rewind_to is not None:
|
|
239
|
+
data = await store.load_session(session_id)
|
|
240
|
+
session = cast(dict[str, Any] | None, data["session"] if data else None)
|
|
241
|
+
existing_messages = data["messages"] if data else []
|
|
242
|
+
_validate_rewind_request(session=session, messages=existing_messages, rewind_to=chat.rewind_to)
|
|
243
243
|
|
|
244
244
|
# All validation passed. Land the rewind marker (if any) and register
|
|
245
245
|
# the user turn in the catalog: first turn creates the entry and sets
|
|
@@ -374,6 +374,7 @@ async def compact_session(
|
|
|
374
374
|
session_id=session_id,
|
|
375
375
|
base_messages=agent.messages,
|
|
376
376
|
agent=agent,
|
|
377
|
+
session_cost_base=data["session_cost"],
|
|
377
378
|
on_complete=store.touch,
|
|
378
379
|
)
|
|
379
380
|
except ActiveRunError as exc:
|
|
@@ -9,7 +9,6 @@ from fastapi import APIRouter, HTTPException, Query
|
|
|
9
9
|
from fastapi import Path as PathParam
|
|
10
10
|
|
|
11
11
|
from mycode.messages import ConversationMessage
|
|
12
|
-
from mycode_cli.runtime import load_session_cost
|
|
13
12
|
from mycode_cli.server.deps import RunManagerDep, StoreDep, resolve_workspace_cwd
|
|
14
13
|
from mycode_cli.server.schemas import SessionCreateRequest, StatusResponse
|
|
15
14
|
|
|
@@ -57,19 +56,18 @@ async def load_session(
|
|
|
57
56
|
) -> dict[str, Any]:
|
|
58
57
|
"""Load a session, overlaying any active in-memory run state."""
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
59
|
+
async with runs.session_operation(session_id):
|
|
60
|
+
active = await runs.snapshot_session(session_id)
|
|
61
|
+
if active:
|
|
62
|
+
return {
|
|
63
|
+
"session": await store.load_metadata(session_id),
|
|
64
|
+
"messages": _redact_document_data(active["messages"]),
|
|
65
|
+
"session_cost": active["session_cost"],
|
|
66
|
+
"active_run": active["run"],
|
|
67
|
+
"pending_events": active["pending_events"],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
data = await store.load_session(session_id)
|
|
73
71
|
|
|
74
72
|
if data is None:
|
|
75
73
|
return {"session": None, "messages": [], "session_cost": None, "active_run": None, "pending_events": []}
|
|
@@ -77,7 +75,7 @@ async def load_session(
|
|
|
77
75
|
return {
|
|
78
76
|
"session": data["session"],
|
|
79
77
|
"messages": _redact_document_data(data["messages"]),
|
|
80
|
-
"session_cost": session_cost,
|
|
78
|
+
"session_cost": data["session_cost"],
|
|
81
79
|
"active_run": None,
|
|
82
80
|
"pending_events": [],
|
|
83
81
|
}
|
|
@@ -168,14 +168,16 @@ def put_settings_endpoint(payload: SettingsRequest) -> dict[str, Any]:
|
|
|
168
168
|
# Three-state api_key merge: when the UI sends null / omits it, we copy the
|
|
169
169
|
# existing literal forward so secrets survive a save without round-tripping.
|
|
170
170
|
existing_providers = existing.get("providers") or {}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
171
|
+
incoming_providers = incoming.get("providers")
|
|
172
|
+
if isinstance(incoming_providers, dict):
|
|
173
|
+
for name, entry in incoming_providers.items():
|
|
174
|
+
if not isinstance(entry, dict) or entry.get("api_key") is not None:
|
|
175
|
+
continue
|
|
176
|
+
prior = existing_providers.get(name) if isinstance(existing_providers, dict) else None
|
|
177
|
+
if isinstance(prior, dict) and "api_key" in prior:
|
|
178
|
+
entry["api_key"] = prior["api_key"]
|
|
179
|
+
else:
|
|
180
|
+
entry.pop("api_key", None)
|
|
179
181
|
|
|
180
182
|
existing_web = existing.get("web") or {}
|
|
181
183
|
incoming_web = incoming.get("web") or {}
|
|
@@ -121,7 +121,7 @@ def list_workspace_files(
|
|
|
121
121
|
|
|
122
122
|
target = (base / dir).resolve(strict=False)
|
|
123
123
|
# Reject traversal outside cwd after resolving symlinks.
|
|
124
|
-
if
|
|
124
|
+
if not target.is_relative_to(base):
|
|
125
125
|
return {"entries": [], "truncated": False, "error": "path outside workspace"}
|
|
126
126
|
if not target.is_dir():
|
|
127
127
|
return {"entries": [], "truncated": False, "error": "not a directory"}
|
|
@@ -133,7 +133,7 @@ def list_workspace_files(
|
|
|
133
133
|
continue
|
|
134
134
|
try:
|
|
135
135
|
resolved = entry.resolve()
|
|
136
|
-
if
|
|
136
|
+
if not resolved.is_relative_to(base):
|
|
137
137
|
continue
|
|
138
138
|
is_dir = entry.is_dir()
|
|
139
139
|
except OSError:
|