mycode-cli 0.18.0__py3-none-any.whl → 0.19.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/runtime.py +6 -11
- mycode_cli/server/routers/chat.py +106 -14
- mycode_cli/server/routers/sessions.py +28 -14
- mycode_cli/server/run_manager.py +161 -40
- mycode_cli/server/schemas.py +26 -14
- mycode_cli/server/static/assets/index-CrhooK9g.js +395 -0
- mycode_cli/server/static/assets/index-D9h4EXyc.css +1 -0
- mycode_cli/server/static/index.html +2 -2
- mycode_cli/sessions.py +82 -33
- mycode_cli/system_prompt.py +11 -1
- mycode_cli/tui/chat.py +134 -36
- mycode_cli/tui/render.py +29 -8
- mycode_cli/tui/terminal.py +60 -13
- {mycode_cli-0.18.0.dist-info → mycode_cli-0.19.0.dist-info}/METADATA +15 -13
- {mycode_cli-0.18.0.dist-info → mycode_cli-0.19.0.dist-info}/RECORD +18 -18
- mycode_cli/server/static/assets/index-C7MhyuLY.js +0 -389
- mycode_cli/server/static/assets/index-D71Nwfrp.css +0 -1
- {mycode_cli-0.18.0.dist-info → mycode_cli-0.19.0.dist-info}/WHEEL +0 -0
- {mycode_cli-0.18.0.dist-info → mycode_cli-0.19.0.dist-info}/entry_points.txt +0 -0
- {mycode_cli-0.18.0.dist-info → mycode_cli-0.19.0.dist-info}/licenses/LICENSE +0 -0
mycode_cli/runtime.py
CHANGED
|
@@ -5,26 +5,21 @@ 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, SessionTotals, sum_session_totals
|
|
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
|
|
12
12
|
from mycode_cli.workspace import CliDeps
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def
|
|
16
|
-
|
|
17
|
-
return sum(known) if known else None
|
|
15
|
+
async def load_session_totals(store: SessionStore, session_id: str) -> SessionTotals:
|
|
16
|
+
"""Load the session's cumulative usage and USD cost from its raw JSONL timeline.
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"""Load the session's cumulative USD cost from its raw JSONL timeline.
|
|
22
|
-
|
|
23
|
-
Counts persisted costs from tool loops, compact summaries, and turns
|
|
24
|
-
discarded by rewind. Records without a cost are skipped.
|
|
18
|
+
Counts tool loops, compact summaries, and turns discarded by rewind.
|
|
19
|
+
Records without usage or cost contribute nothing to that total.
|
|
25
20
|
"""
|
|
26
21
|
|
|
27
|
-
return
|
|
22
|
+
return sum_session_totals(await store.load_raw_messages(session_id))
|
|
28
23
|
|
|
29
24
|
|
|
30
25
|
def build_agent(
|
|
@@ -21,7 +21,7 @@ from mycode.attachments import (
|
|
|
21
21
|
detect_document_mime_type,
|
|
22
22
|
detect_image_mime_type,
|
|
23
23
|
)
|
|
24
|
-
from mycode.compact import has_compactable_history
|
|
24
|
+
from mycode.compact import DEFAULT_COMPACT_THRESHOLD, has_compactable_history
|
|
25
25
|
from mycode.messages import (
|
|
26
26
|
ConversationMessage,
|
|
27
27
|
build_message,
|
|
@@ -30,6 +30,7 @@ from mycode.messages import (
|
|
|
30
30
|
image_block,
|
|
31
31
|
text_block,
|
|
32
32
|
)
|
|
33
|
+
from mycode.models import ModelMetadata
|
|
33
34
|
from mycode_cli.config import (
|
|
34
35
|
ResolvedProvider,
|
|
35
36
|
get_settings,
|
|
@@ -40,19 +41,21 @@ from mycode_cli.config import (
|
|
|
40
41
|
resolve_provider_choices,
|
|
41
42
|
)
|
|
42
43
|
from mycode_cli.permissions import ToolReviewDecision, ToolReviewRequest
|
|
43
|
-
from mycode_cli.runtime import build_agent,
|
|
44
|
+
from mycode_cli.runtime import build_agent, load_session_totals
|
|
44
45
|
from mycode_cli.server.deps import RunManagerDep, StoreDep, resolve_workspace_cwd
|
|
45
|
-
from mycode_cli.server.run_manager import ActiveRunError
|
|
46
|
+
from mycode_cli.server.run_manager import ActiveRunError, RunAgent
|
|
46
47
|
from mycode_cli.server.schemas import (
|
|
47
48
|
CancelRunResponse,
|
|
48
49
|
ChatRequest,
|
|
49
50
|
ChatResponse,
|
|
50
51
|
CompactRequest,
|
|
51
|
-
CompactResponse,
|
|
52
52
|
DecideRequest,
|
|
53
|
+
PendingInputRequest,
|
|
53
54
|
RunInfo,
|
|
55
|
+
RunResponse,
|
|
54
56
|
StatusResponse,
|
|
55
57
|
StreamEvent,
|
|
58
|
+
UserInputRequest,
|
|
56
59
|
)
|
|
57
60
|
from mycode_cli.system_prompt import build_skill_snapshot_blocks, discover_slash_skills
|
|
58
61
|
from mycode_cli.workspace import resolve_path
|
|
@@ -86,7 +89,7 @@ def _read_workspace_text_attachment(rel_path: str, *, name: str | None, cwd: str
|
|
|
86
89
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
87
90
|
|
|
88
91
|
|
|
89
|
-
async def _build_user_message(chat:
|
|
92
|
+
async def _build_user_message(chat: UserInputRequest, cwd: str) -> ConversationMessage:
|
|
90
93
|
if not chat.input:
|
|
91
94
|
text = str(chat.message or "").strip()
|
|
92
95
|
snapshots = await asyncio.to_thread(build_skill_snapshot_blocks, text, cwd)
|
|
@@ -143,6 +146,14 @@ async def _build_user_message(chat: ChatRequest, cwd: str) -> ConversationMessag
|
|
|
143
146
|
return build_message("user", blocks)
|
|
144
147
|
|
|
145
148
|
|
|
149
|
+
def _check_input_support(message: ConversationMessage, model: ModelMetadata | RunAgent) -> None:
|
|
150
|
+
content_types = {b.get("type") for b in (message.get("content") or []) if isinstance(b, dict)}
|
|
151
|
+
if "image" in content_types and not model.supports_image_input:
|
|
152
|
+
raise HTTPException(status_code=400, detail="current model does not support image input")
|
|
153
|
+
if "document" in content_types and not model.supports_pdf_input:
|
|
154
|
+
raise HTTPException(status_code=400, detail="current model does not support PDF input")
|
|
155
|
+
|
|
156
|
+
|
|
146
157
|
def _validate_rewind_request(
|
|
147
158
|
*,
|
|
148
159
|
session: dict[str, Any] | None,
|
|
@@ -192,11 +203,7 @@ async def chat(chat: ChatRequest, store: StoreDep, runs: RunManagerDep) -> ChatR
|
|
|
192
203
|
model=resolved.model,
|
|
193
204
|
model_config=model_config,
|
|
194
205
|
)
|
|
195
|
-
|
|
196
|
-
if "image" in content_types and not model_meta.supports_image_input:
|
|
197
|
-
raise HTTPException(status_code=400, detail="current model does not support image input")
|
|
198
|
-
if "document" in content_types and not model_meta.supports_pdf_input:
|
|
199
|
-
raise HTTPException(status_code=400, detail="current model does not support PDF input")
|
|
206
|
+
_check_input_support(user_message, model_meta)
|
|
200
207
|
|
|
201
208
|
reasoning_effort = resolved.reasoning_effort
|
|
202
209
|
if "reasoning_effort" in chat.model_fields_set:
|
|
@@ -274,10 +281,11 @@ async def chat(chat: ChatRequest, store: StoreDep, runs: RunManagerDep) -> ChatR
|
|
|
274
281
|
try:
|
|
275
282
|
run = await runs.start_run(
|
|
276
283
|
session_id=session_id,
|
|
284
|
+
cwd=cwd,
|
|
277
285
|
user_message=user_message,
|
|
278
286
|
base_messages=agent.messages,
|
|
279
287
|
agent=agent,
|
|
280
|
-
|
|
288
|
+
session_base=await load_session_totals(store, session_id),
|
|
281
289
|
)
|
|
282
290
|
except ActiveRunError as exc:
|
|
283
291
|
existing = await runs.get_run(exc.run_id)
|
|
@@ -339,7 +347,7 @@ async def compact_session(
|
|
|
339
347
|
body: CompactRequest,
|
|
340
348
|
store: StoreDep,
|
|
341
349
|
runs: RunManagerDep,
|
|
342
|
-
) ->
|
|
350
|
+
) -> RunResponse:
|
|
343
351
|
"""Start a compact run: summarize the session and append one compact marker."""
|
|
344
352
|
|
|
345
353
|
async with runs.session_operation(session_id):
|
|
@@ -372,9 +380,10 @@ async def compact_session(
|
|
|
372
380
|
try:
|
|
373
381
|
run = await runs.start_compact(
|
|
374
382
|
session_id=session_id,
|
|
383
|
+
cwd=cwd,
|
|
375
384
|
base_messages=agent.messages,
|
|
376
385
|
agent=agent,
|
|
377
|
-
|
|
386
|
+
session_base=data["totals"],
|
|
378
387
|
on_complete=store.touch,
|
|
379
388
|
)
|
|
380
389
|
except ActiveRunError as exc:
|
|
@@ -384,7 +393,86 @@ async def compact_session(
|
|
|
384
393
|
detail["run"] = existing.info()
|
|
385
394
|
raise HTTPException(status_code=409, detail=detail) from exc
|
|
386
395
|
|
|
387
|
-
return
|
|
396
|
+
return RunResponse(run=RunInfo.model_validate(run))
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@router.post("/runs/{run_id}/steer")
|
|
400
|
+
async def steer_run(
|
|
401
|
+
run_id: Annotated[str, PathParam(min_length=1)], body: PendingInputRequest, runs: RunManagerDep
|
|
402
|
+
) -> RunResponse:
|
|
403
|
+
"""Hand a user message to the running chat for its next step boundary."""
|
|
404
|
+
|
|
405
|
+
state = await runs.get_run(run_id)
|
|
406
|
+
if state is None:
|
|
407
|
+
raise HTTPException(status_code=404, detail="run not found")
|
|
408
|
+
message = await _build_user_message(body, state.cwd)
|
|
409
|
+
message["meta"] = {"input_id": body.input_id}
|
|
410
|
+
_check_input_support(message, state.agent)
|
|
411
|
+
if not state.agent.steer(message):
|
|
412
|
+
raise HTTPException(
|
|
413
|
+
status_code=409,
|
|
414
|
+
detail={"message": "run is not accepting steers", "run": state.info()},
|
|
415
|
+
)
|
|
416
|
+
return RunResponse(run=RunInfo.model_validate(state.info()))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@router.post("/sessions/{session_id}/queue")
|
|
420
|
+
async def queue_message(
|
|
421
|
+
session_id: Annotated[str, PathParam(min_length=1)],
|
|
422
|
+
body: PendingInputRequest,
|
|
423
|
+
store: StoreDep,
|
|
424
|
+
runs: RunManagerDep,
|
|
425
|
+
) -> RunResponse:
|
|
426
|
+
"""Queue a user message as the next turn of the session's running chat."""
|
|
427
|
+
|
|
428
|
+
state = await runs.get_active_run(session_id)
|
|
429
|
+
if state is None:
|
|
430
|
+
raise HTTPException(status_code=409, detail={"message": "session has no running chat to queue on"})
|
|
431
|
+
message = await _build_user_message(body, state.cwd)
|
|
432
|
+
message["meta"] = {"input_id": body.input_id}
|
|
433
|
+
_check_input_support(message, state.agent)
|
|
434
|
+
if not await runs.enqueue(state, message):
|
|
435
|
+
raise HTTPException(status_code=409, detail={"message": "session has no running chat to queue on"})
|
|
436
|
+
await store.record_user_turn(
|
|
437
|
+
session_id,
|
|
438
|
+
cwd=state.cwd,
|
|
439
|
+
text=flatten_message_text(message, include_thinking=False),
|
|
440
|
+
)
|
|
441
|
+
return RunResponse(run=RunInfo.model_validate(state.info()))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@router.delete("/sessions/{session_id}/queue/{input_id}")
|
|
445
|
+
async def remove_queued_message(
|
|
446
|
+
session_id: Annotated[str, PathParam(min_length=1)],
|
|
447
|
+
input_id: Annotated[str, PathParam(min_length=1)],
|
|
448
|
+
runs: RunManagerDep,
|
|
449
|
+
) -> RunResponse:
|
|
450
|
+
"""Remove a queued message that has not been delivered."""
|
|
451
|
+
|
|
452
|
+
run = await runs.remove_queued(session_id, input_id)
|
|
453
|
+
if run is None:
|
|
454
|
+
raise HTTPException(status_code=404, detail="queued message not found")
|
|
455
|
+
return RunResponse(run=RunInfo.model_validate(run))
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@router.post("/sessions/{session_id}/queue/{input_id}/steer")
|
|
459
|
+
async def steer_queued_message(
|
|
460
|
+
session_id: Annotated[str, PathParam(min_length=1)],
|
|
461
|
+
input_id: Annotated[str, PathParam(min_length=1)],
|
|
462
|
+
runs: RunManagerDep,
|
|
463
|
+
) -> RunResponse:
|
|
464
|
+
"""Move a queued message into the current turn, keeping it as built."""
|
|
465
|
+
|
|
466
|
+
state = await runs.get_active_run(session_id)
|
|
467
|
+
moved = None if state is None else await runs.steer_queued(state, input_id)
|
|
468
|
+
if state is None or moved is None:
|
|
469
|
+
raise HTTPException(status_code=404, detail="queued message not found")
|
|
470
|
+
if not moved:
|
|
471
|
+
raise HTTPException(
|
|
472
|
+
status_code=409,
|
|
473
|
+
detail={"message": "run is not accepting steers", "run": state.info()},
|
|
474
|
+
)
|
|
475
|
+
return RunResponse(run=RunInfo.model_validate(state.info()))
|
|
388
476
|
|
|
389
477
|
|
|
390
478
|
@router.get("/config")
|
|
@@ -454,6 +542,10 @@ async def get_config(cwd: Annotated[str | None, Query()] = None) -> dict[str, An
|
|
|
454
542
|
"cwd_exists": os.path.isdir(resolved_cwd),
|
|
455
543
|
"project": settings.project,
|
|
456
544
|
"config_paths": settings.config_paths,
|
|
545
|
+
# Effective for this cwd; 0 disables automatic compaction.
|
|
546
|
+
"compact_threshold": (
|
|
547
|
+
settings.compact_threshold if settings.compact_threshold is not None else DEFAULT_COMPACT_THRESHOLD
|
|
548
|
+
),
|
|
457
549
|
"skills": [{"name": skill.name, "description": skill.description} for skill in skills],
|
|
458
550
|
"setup_error": setup_error,
|
|
459
551
|
}
|
|
@@ -11,6 +11,7 @@ from fastapi import Path as PathParam
|
|
|
11
11
|
from mycode.messages import ConversationMessage
|
|
12
12
|
from mycode_cli.server.deps import RunManagerDep, StoreDep, resolve_workspace_cwd
|
|
13
13
|
from mycode_cli.server.schemas import SessionCreateRequest, StatusResponse
|
|
14
|
+
from mycode_cli.sessions import SessionTotals
|
|
14
15
|
|
|
15
16
|
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
|
16
17
|
|
|
@@ -50,6 +51,21 @@ async def list_sessions(
|
|
|
50
51
|
return {"sessions": sessions}
|
|
51
52
|
|
|
52
53
|
|
|
54
|
+
@router.get("/search")
|
|
55
|
+
async def search_sessions(
|
|
56
|
+
store: StoreDep,
|
|
57
|
+
runs: RunManagerDep,
|
|
58
|
+
q: Annotated[str, Query(min_length=1)],
|
|
59
|
+
cwd: Annotated[str | None, Query()] = None,
|
|
60
|
+
limit: Annotated[int, Query(ge=1)] = 50,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
results = await store.search_sessions(q, cwd=cwd, limit=limit)
|
|
63
|
+
for result in results:
|
|
64
|
+
session = result["session"]
|
|
65
|
+
session["is_running"] = await runs.has_active_run(str(session["id"]))
|
|
66
|
+
return {"results": results}
|
|
67
|
+
|
|
68
|
+
|
|
53
69
|
@router.get("/{session_id}")
|
|
54
70
|
async def load_session(
|
|
55
71
|
session_id: Annotated[str, PathParam(min_length=1)], store: StoreDep, runs: RunManagerDep
|
|
@@ -62,22 +78,31 @@ async def load_session(
|
|
|
62
78
|
return {
|
|
63
79
|
"session": await store.load_metadata(session_id),
|
|
64
80
|
"messages": _redact_document_data(active["messages"]),
|
|
65
|
-
|
|
81
|
+
**active["totals"].payload(),
|
|
66
82
|
"active_run": active["run"],
|
|
67
83
|
"pending_events": active["pending_events"],
|
|
84
|
+
"pending": {kind: _redact_document_data(messages) for kind, messages in active["pending"].items()},
|
|
68
85
|
}
|
|
69
86
|
|
|
70
87
|
data = await store.load_session(session_id)
|
|
71
88
|
|
|
72
89
|
if data is None:
|
|
73
|
-
return {
|
|
90
|
+
return {
|
|
91
|
+
"session": None,
|
|
92
|
+
"messages": [],
|
|
93
|
+
**SessionTotals().payload(),
|
|
94
|
+
"active_run": None,
|
|
95
|
+
"pending_events": [],
|
|
96
|
+
"pending": {"steers": [], "queue": []},
|
|
97
|
+
}
|
|
74
98
|
|
|
75
99
|
return {
|
|
76
100
|
"session": data["session"],
|
|
77
101
|
"messages": _redact_document_data(data["messages"]),
|
|
78
|
-
|
|
102
|
+
**data["totals"].payload(),
|
|
79
103
|
"active_run": None,
|
|
80
104
|
"pending_events": [],
|
|
105
|
+
"pending": {"steers": [], "queue": []},
|
|
81
106
|
}
|
|
82
107
|
|
|
83
108
|
|
|
@@ -90,14 +115,3 @@ async def delete_session(
|
|
|
90
115
|
raise HTTPException(status_code=409, detail="session has a running task")
|
|
91
116
|
await store.delete_session(session_id)
|
|
92
117
|
return StatusResponse(status="ok")
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
@router.post("/{session_id}/clear")
|
|
96
|
-
async def clear_session(
|
|
97
|
-
session_id: Annotated[str, PathParam(min_length=1)], store: StoreDep, runs: RunManagerDep
|
|
98
|
-
) -> StatusResponse:
|
|
99
|
-
async with runs.session_operation(session_id):
|
|
100
|
-
if await runs.has_active_run(session_id):
|
|
101
|
-
raise HTTPException(status_code=409, detail="session has a running task")
|
|
102
|
-
await store.clear_session(session_id)
|
|
103
|
-
return StatusResponse(status="ok")
|
mycode_cli/server/run_manager.py
CHANGED
|
@@ -8,15 +8,15 @@ import time
|
|
|
8
8
|
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
|
|
9
9
|
from contextlib import aclosing, asynccontextmanager
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
|
+
from functools import partial
|
|
11
12
|
from typing import Any, Literal, Protocol
|
|
12
13
|
from uuid import uuid4
|
|
13
14
|
from weakref import WeakValueDictionary
|
|
14
15
|
|
|
15
|
-
from mycode.agent import Event
|
|
16
|
-
from mycode.messages import ConversationMessage
|
|
16
|
+
from mycode.agent import Event, PersistCallback
|
|
17
|
+
from mycode.messages import ConversationMessage, merge_user_messages
|
|
17
18
|
from mycode_cli.permissions import ToolReviewDecision
|
|
18
|
-
from mycode_cli.
|
|
19
|
-
from mycode_cli.sessions import sum_session_cost
|
|
19
|
+
from mycode_cli.sessions import SessionTotals, sum_session_totals
|
|
20
20
|
|
|
21
21
|
RunStatus = Literal["running", "completed", "failed", "cancelled"]
|
|
22
22
|
RunKind = Literal["chat", "compact"]
|
|
@@ -44,10 +44,18 @@ class ActiveRunError(RuntimeError):
|
|
|
44
44
|
class RunAgent(Protocol):
|
|
45
45
|
model: str
|
|
46
46
|
context_window: int
|
|
47
|
+
supports_image_input: bool
|
|
48
|
+
supports_pdf_input: bool
|
|
47
49
|
|
|
48
50
|
def cancel(self) -> None: ...
|
|
49
51
|
|
|
50
|
-
def
|
|
52
|
+
def steer(self, message: ConversationMessage) -> bool: ...
|
|
53
|
+
|
|
54
|
+
def pending_steers(self) -> list[ConversationMessage]: ...
|
|
55
|
+
|
|
56
|
+
def achat(
|
|
57
|
+
self, user_input: str | ConversationMessage, *, on_persist: PersistCallback | None = None
|
|
58
|
+
) -> AsyncGenerator[Event, None]: ...
|
|
51
59
|
|
|
52
60
|
def acompact(self) -> Awaitable[ConversationMessage]: ...
|
|
53
61
|
|
|
@@ -58,13 +66,25 @@ class RunState:
|
|
|
58
66
|
|
|
59
67
|
id: str
|
|
60
68
|
session_id: str
|
|
69
|
+
cwd: str
|
|
61
70
|
base_messages: list[ConversationMessage]
|
|
62
71
|
agent: RunAgent
|
|
63
72
|
kind: RunKind = "chat"
|
|
64
73
|
user_message: ConversationMessage | None = None
|
|
65
|
-
#
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
# Queued messages the current turn continues from, until their merged
|
|
75
|
+
# message is announced; snapshots list them as still queued.
|
|
76
|
+
delivering: list[ConversationMessage] = field(default_factory=list)
|
|
77
|
+
# Messages for the next turn of a chat run, each carrying ``meta.input_id``.
|
|
78
|
+
queue: list[ConversationMessage] = field(default_factory=list)
|
|
79
|
+
# Messages the run committed, and how far snapshots return them as history:
|
|
80
|
+
# through the last committed user message, whose events end at
|
|
81
|
+
# ``history_seq``. The events after it rebuild the rest, as on the live stream.
|
|
82
|
+
committed: list[ConversationMessage] = field(default_factory=list)
|
|
83
|
+
history_len: int = 0
|
|
84
|
+
history_seq: int = 0
|
|
85
|
+
# Pre-run history totals, combined with the cumulative turn totals on each update.
|
|
86
|
+
session_base: SessionTotals = field(default_factory=SessionTotals)
|
|
87
|
+
session_totals: SessionTotals = field(default_factory=SessionTotals)
|
|
68
88
|
on_complete: RunCompletionCallback | None = None
|
|
69
89
|
status: RunStatus = "running"
|
|
70
90
|
error: str | None = None
|
|
@@ -105,36 +125,40 @@ class RunManager:
|
|
|
105
125
|
self,
|
|
106
126
|
*,
|
|
107
127
|
session_id: str,
|
|
128
|
+
cwd: str,
|
|
108
129
|
user_message: ConversationMessage,
|
|
109
130
|
base_messages: list[ConversationMessage],
|
|
110
131
|
agent: RunAgent,
|
|
111
|
-
|
|
132
|
+
session_base: SessionTotals | None = None,
|
|
112
133
|
) -> dict[str, Any]:
|
|
113
134
|
return await self._start(
|
|
114
135
|
session_id=session_id,
|
|
136
|
+
cwd=cwd,
|
|
115
137
|
kind="chat",
|
|
116
138
|
user_message=user_message,
|
|
117
139
|
base_messages=base_messages,
|
|
118
140
|
agent=agent,
|
|
119
|
-
|
|
141
|
+
session_base=session_base,
|
|
120
142
|
)
|
|
121
143
|
|
|
122
144
|
async def start_compact(
|
|
123
145
|
self,
|
|
124
146
|
*,
|
|
125
147
|
session_id: str,
|
|
148
|
+
cwd: str,
|
|
126
149
|
base_messages: list[ConversationMessage],
|
|
127
150
|
agent: RunAgent,
|
|
128
|
-
|
|
151
|
+
session_base: SessionTotals | None = None,
|
|
129
152
|
on_complete: RunCompletionCallback | None = None,
|
|
130
153
|
) -> dict[str, Any]:
|
|
131
154
|
return await self._start(
|
|
132
155
|
session_id=session_id,
|
|
156
|
+
cwd=cwd,
|
|
133
157
|
kind="compact",
|
|
134
158
|
user_message=None,
|
|
135
159
|
base_messages=base_messages,
|
|
136
160
|
agent=agent,
|
|
137
|
-
|
|
161
|
+
session_base=session_base,
|
|
138
162
|
on_complete=on_complete,
|
|
139
163
|
)
|
|
140
164
|
|
|
@@ -142,14 +166,16 @@ class RunManager:
|
|
|
142
166
|
self,
|
|
143
167
|
*,
|
|
144
168
|
session_id: str,
|
|
169
|
+
cwd: str,
|
|
145
170
|
kind: RunKind,
|
|
146
171
|
user_message: ConversationMessage | None,
|
|
147
172
|
base_messages: list[ConversationMessage],
|
|
148
173
|
agent: RunAgent,
|
|
149
|
-
|
|
174
|
+
session_base: SessionTotals | None = None,
|
|
150
175
|
on_complete: RunCompletionCallback | None = None,
|
|
151
176
|
) -> dict[str, Any]:
|
|
152
177
|
await self._prune_finished_runs()
|
|
178
|
+
session_base = session_base or SessionTotals()
|
|
153
179
|
|
|
154
180
|
async with self._lock:
|
|
155
181
|
existing = self._active_by_session.get(session_id)
|
|
@@ -159,10 +185,11 @@ class RunManager:
|
|
|
159
185
|
state = RunState(
|
|
160
186
|
id=uuid4().hex,
|
|
161
187
|
session_id=session_id,
|
|
188
|
+
cwd=cwd,
|
|
162
189
|
kind=kind,
|
|
163
190
|
user_message=copy.deepcopy(user_message),
|
|
164
|
-
|
|
165
|
-
|
|
191
|
+
session_base=session_base,
|
|
192
|
+
session_totals=session_base,
|
|
166
193
|
base_messages=copy.deepcopy(base_messages),
|
|
167
194
|
agent=agent,
|
|
168
195
|
on_complete=on_complete,
|
|
@@ -177,6 +204,52 @@ class RunManager:
|
|
|
177
204
|
async with self._lock:
|
|
178
205
|
return self._runs_by_id.get(run_id)
|
|
179
206
|
|
|
207
|
+
async def get_active_run(self, session_id: str) -> RunState | None:
|
|
208
|
+
async with self._lock:
|
|
209
|
+
return self._active_by_session.get(session_id)
|
|
210
|
+
|
|
211
|
+
async def enqueue(self, state: RunState, message: ConversationMessage) -> bool:
|
|
212
|
+
"""Queue a message for the next turn of an active chat run."""
|
|
213
|
+
|
|
214
|
+
async with self._lock:
|
|
215
|
+
if (
|
|
216
|
+
state.kind != "chat"
|
|
217
|
+
or state.cancel_requested
|
|
218
|
+
or self._active_by_session.get(state.session_id) is not state
|
|
219
|
+
):
|
|
220
|
+
return False
|
|
221
|
+
state.queue.append(copy.deepcopy(message))
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
async def remove_queued(self, session_id: str, input_id: str) -> dict[str, Any] | None:
|
|
225
|
+
"""Remove one pending queued message; returns the run, or ``None`` when it is not pending."""
|
|
226
|
+
|
|
227
|
+
async with self._lock:
|
|
228
|
+
state = self._active_by_session.get(session_id)
|
|
229
|
+
if state is None:
|
|
230
|
+
return None
|
|
231
|
+
for index, message in enumerate(state.queue):
|
|
232
|
+
if (message.get("meta") or {}).get("input_id") == input_id:
|
|
233
|
+
del state.queue[index]
|
|
234
|
+
return state.info()
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
async def steer_queued(self, state: RunState, input_id: str) -> bool | None:
|
|
238
|
+
"""Move one queued message to the run's steers.
|
|
239
|
+
|
|
240
|
+
Returns ``None`` when it is not pending, ``False`` when the run is not
|
|
241
|
+
accepting steers (the message stays queued).
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
async with self._lock:
|
|
245
|
+
for index, message in enumerate(state.queue):
|
|
246
|
+
if (message.get("meta") or {}).get("input_id") == input_id:
|
|
247
|
+
if not state.agent.steer(message):
|
|
248
|
+
return False
|
|
249
|
+
del state.queue[index]
|
|
250
|
+
return True
|
|
251
|
+
return None
|
|
252
|
+
|
|
180
253
|
async def snapshot_session(self, session_id: str) -> dict[str, Any] | None:
|
|
181
254
|
"""Return a reconnect snapshot for the active session."""
|
|
182
255
|
|
|
@@ -189,14 +262,19 @@ class RunManager:
|
|
|
189
262
|
async with state.condition:
|
|
190
263
|
if state.status != "running":
|
|
191
264
|
return None
|
|
192
|
-
|
|
193
|
-
if state.user_message is not None:
|
|
194
|
-
|
|
265
|
+
history = state.committed[: state.history_len]
|
|
266
|
+
if not history and state.user_message is not None:
|
|
267
|
+
# Not committed yet: the input still leads the turn.
|
|
268
|
+
history = [state.user_message]
|
|
195
269
|
return {
|
|
196
270
|
"run": state.info(),
|
|
197
|
-
"messages":
|
|
198
|
-
"pending_events":
|
|
199
|
-
"
|
|
271
|
+
"messages": copy.deepcopy(state.base_messages + history),
|
|
272
|
+
"pending_events": [event for event in state.events if event["seq"] > state.history_seq],
|
|
273
|
+
"pending": {
|
|
274
|
+
"steers": copy.deepcopy(state.agent.pending_steers()),
|
|
275
|
+
"queue": copy.deepcopy(state.delivering + state.queue),
|
|
276
|
+
},
|
|
277
|
+
"totals": state.session_totals,
|
|
200
278
|
}
|
|
201
279
|
|
|
202
280
|
@asynccontextmanager
|
|
@@ -371,20 +449,49 @@ class RunManager:
|
|
|
371
449
|
if state.on_complete is not None:
|
|
372
450
|
await state.on_complete(state.session_id)
|
|
373
451
|
await self._append_event(
|
|
374
|
-
state, Event("compact", {"trigger": "manual"}),
|
|
452
|
+
state, Event("compact", {"trigger": "manual"}), compact_totals=sum_session_totals([marker])
|
|
375
453
|
)
|
|
376
454
|
else:
|
|
377
455
|
assert state.user_message is not None
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
456
|
+
message = state.user_message
|
|
457
|
+
on_persist = partial(self._record_commit, state)
|
|
458
|
+
while True:
|
|
459
|
+
async with aclosing(state.agent.achat(message, on_persist=on_persist)) as stream:
|
|
460
|
+
async for event in stream:
|
|
461
|
+
# achat() commits its user message before its first
|
|
462
|
+
# event, so a queued turn is announced only once it
|
|
463
|
+
# is in the session; a failed commit reaches the
|
|
464
|
+
# client as an error with nothing delivered.
|
|
465
|
+
if state.delivering:
|
|
466
|
+
await self._append_event(state, Event("user_message", {"message": message}))
|
|
467
|
+
state.delivering = []
|
|
468
|
+
if event.type == "retry":
|
|
469
|
+
continue
|
|
470
|
+
if event.type == "error":
|
|
471
|
+
last_error = str(event.data.get("message") or "unknown error")
|
|
472
|
+
elif event.type == "cancelled":
|
|
473
|
+
cancelled = True
|
|
474
|
+
continue
|
|
475
|
+
await self._append_event(state, event)
|
|
476
|
+
if last_error is not None or cancelled:
|
|
477
|
+
break
|
|
478
|
+
# Stop requests and enqueue() run on this loop, so they land
|
|
479
|
+
# either before this hold or once the next achat() has its
|
|
480
|
+
# run: a stop after the take still ends the queued turn.
|
|
481
|
+
async with self._lock:
|
|
482
|
+
queued, state.queue = state.queue, []
|
|
483
|
+
# A stop drops the queue; the client restores its items.
|
|
484
|
+
if state.cancel_requested:
|
|
485
|
+
break
|
|
486
|
+
if not queued:
|
|
487
|
+
# Leaving the active map with the final take means
|
|
488
|
+
# no later enqueue is accepted and then dropped.
|
|
489
|
+
if self._active_by_session.get(state.session_id) is state:
|
|
490
|
+
del self._active_by_session[state.session_id]
|
|
491
|
+
break
|
|
492
|
+
# The queued turn continues on the same agent and model.
|
|
493
|
+
state.delivering = queued
|
|
494
|
+
message = merge_user_messages(queued, steer=False)
|
|
388
495
|
except asyncio.CancelledError:
|
|
389
496
|
# Manual compaction and external task cancellation both end the run.
|
|
390
497
|
cancelled = True
|
|
@@ -405,23 +512,37 @@ class RunManager:
|
|
|
405
512
|
|
|
406
513
|
await self._finish_run(state, status="completed")
|
|
407
514
|
|
|
408
|
-
async def
|
|
515
|
+
async def _record_commit(self, state: RunState, message: ConversationMessage) -> None:
|
|
516
|
+
async with state.condition:
|
|
517
|
+
state.committed.append(message)
|
|
518
|
+
# A steer or queued message enters history with its user_message
|
|
519
|
+
# event, which clients apply as the start of its segment.
|
|
520
|
+
if message.get("role") == "user" and "input_ids" not in (message.get("meta") or {}):
|
|
521
|
+
state.history_len = len(state.committed)
|
|
522
|
+
state.history_seq = state.next_seq - 1
|
|
523
|
+
|
|
524
|
+
async def _append_event(
|
|
525
|
+
self, state: RunState, event: Event, *, compact_totals: SessionTotals | None = None
|
|
526
|
+
) -> None:
|
|
409
527
|
async with state.condition:
|
|
410
|
-
if event.type == "
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
state.
|
|
528
|
+
if event.type == "user_message":
|
|
529
|
+
# A delivered steer or queued turn starts a new usage segment.
|
|
530
|
+
state.session_base = state.session_totals
|
|
531
|
+
state.history_len = len(state.committed)
|
|
532
|
+
state.history_seq = state.next_seq
|
|
533
|
+
elif event.type == "usage":
|
|
534
|
+
state.session_totals = state.session_base.add(event.data.get("turn_usage"), event.data.get("turn_cost"))
|
|
414
535
|
event = Event(
|
|
415
536
|
"usage",
|
|
416
537
|
{
|
|
417
538
|
**event.data,
|
|
418
539
|
"context_window": state.agent.context_window,
|
|
419
540
|
"model": state.agent.model,
|
|
420
|
-
|
|
541
|
+
**state.session_totals.payload(),
|
|
421
542
|
},
|
|
422
543
|
)
|
|
423
|
-
elif event.type == "compact" and
|
|
424
|
-
state.
|
|
544
|
+
elif event.type == "compact" and compact_totals is not None:
|
|
545
|
+
state.session_totals = state.session_base.add(compact_totals.usage, compact_totals.cost)
|
|
425
546
|
|
|
426
547
|
if event.type == "tool_done":
|
|
427
548
|
tool_use_id = event.data.get("tool_use_id")
|