stcode 0.0.1a0__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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session — one append-only JSONL file, three readers.
|
|
3
|
+
|
|
4
|
+
`~/.stcode/sessions/<id>.jsonl`. Every event goes in, including the ones the model
|
|
5
|
+
never sees:
|
|
6
|
+
|
|
7
|
+
* `messages()` — history for the gateway, folded into provider `Message`s.
|
|
8
|
+
* the file itself — the trajectory. Agent → tool → sub-agent leaves no natural stack
|
|
9
|
+
trace, so this *is* the stack trace.
|
|
10
|
+
* `tail()` — raw records for the supervisor, which needs what the model is not shown.
|
|
11
|
+
|
|
12
|
+
**Append-only, and that is load-bearing.** No rewriting, no branch, no fork: `cp
|
|
13
|
+
session.jsonl` is the branching feature. It is also what makes `append()` one `write`
|
|
14
|
+
+ `flush`, and so safe to call synchronously from the agent loop — the one exception to
|
|
15
|
+
"no blocking I/O in the loop", because ~20µs of buffered write beats the machinery to
|
|
16
|
+
avoid it.
|
|
17
|
+
|
|
18
|
+
Two consequences of that rule, both here:
|
|
19
|
+
|
|
20
|
+
* **`defer=True` holds the file back until the first message.** A session id can exist
|
|
21
|
+
with nothing behind it, which is what lets the UI's `/clear` be "start another one"
|
|
22
|
+
instead of a directory of empty transcripts.
|
|
23
|
+
* **A setting is changed by appending another `meta` record**, never by editing the
|
|
24
|
+
first one. `meta()` is what the session started as; `overrides()` is what has been
|
|
25
|
+
changed since. That is how `/model` reaches a running agent.
|
|
26
|
+
|
|
27
|
+
Swapping to a live database later replaces this class; `Agent` sees the same four
|
|
28
|
+
methods. Do not abstract for that now — a class with four methods **is** the
|
|
29
|
+
abstraction.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import builtins
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Iterator, Sequence, TextIO
|
|
40
|
+
|
|
41
|
+
from stcode.core.common.ids import new_id
|
|
42
|
+
from stcode.core.providers.types import (
|
|
43
|
+
ContentBlock,
|
|
44
|
+
Message,
|
|
45
|
+
TextBlock,
|
|
46
|
+
ToolResultBlock,
|
|
47
|
+
ToolUseBlock,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
DEFAULT_SESSION_DIR = "~/.stcode/sessions"
|
|
51
|
+
|
|
52
|
+
MODEL_VISIBLE = frozenset({"user", "assistant", "tool_call", "tool_result", "supervisor", "inbox"})
|
|
53
|
+
"""Record types `messages()` folds into history. `meta`, `usage` and `error` are for
|
|
54
|
+
the human reading the trajectory and for the supervisor — sending the model its own
|
|
55
|
+
token counts is paying to tell it something it cannot act on."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
META_BOOKKEEPING = frozenset({"type", "ts", "id", "parent", "agent_name"})
|
|
59
|
+
"""Keys a later `meta` record carries but that are not settings. `overrides()` drops
|
|
60
|
+
them, so a client can hand the whole dict to the gateway without sieving it first."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sessions_dir(directory: str | Path | None = None) -> Path:
|
|
64
|
+
return Path(os.path.expanduser(str(directory or DEFAULT_SESSION_DIR)))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Session:
|
|
68
|
+
"""An append-only JSONL transcript of one agent's work.
|
|
69
|
+
|
|
70
|
+
`builtins.list[...]` in the annotations below is not decoration: this class has a
|
|
71
|
+
classmethod called `list`, which shadows the builtin inside the class body, so a
|
|
72
|
+
bare `list[dict[str, Any]]` on a method resolves to the classmethod and every
|
|
73
|
+
annotation using it is quietly wrong. The name `Session.list()` is worth keeping;
|
|
74
|
+
the four extra characters are the price.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
path: Path,
|
|
80
|
+
*,
|
|
81
|
+
records: builtins.list[dict[str, Any]] | None = None,
|
|
82
|
+
defer: bool = False,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Open `path`, adopting whatever is already in it.
|
|
85
|
+
|
|
86
|
+
Appending to a file whose earlier content was never loaded breaks the premise:
|
|
87
|
+
`messages()` would describe a shorter conversation than the file does, and the
|
|
88
|
+
trajectory would read as a continuation the model never saw. Pointing at an
|
|
89
|
+
existing file means resuming it — the file is the truth, not the object. Pass
|
|
90
|
+
`records` only when you have already read them.
|
|
91
|
+
|
|
92
|
+
`defer=True` does not open or create anything. The first record that is not
|
|
93
|
+
`meta` does both, and until then this is an id with a `meta` record behind it.
|
|
94
|
+
"""
|
|
95
|
+
self.path = path
|
|
96
|
+
self.id = path.stem
|
|
97
|
+
if records is None:
|
|
98
|
+
records = list(read_records(path)) if path.is_file() else []
|
|
99
|
+
self._records: builtins.list[dict[str, Any]] = records
|
|
100
|
+
self._held: builtins.list[dict[str, Any]] = []
|
|
101
|
+
"""Records appended before the file was opened. Written, in order, by `_start`."""
|
|
102
|
+
|
|
103
|
+
self._chosen: dict[str, Any] = {}
|
|
104
|
+
"""Settings changed through `set_meta` **before** the session had a file.
|
|
105
|
+
|
|
106
|
+
Those merge into the pending first `meta` record rather than appending a second
|
|
107
|
+
one, which keeps the file clean — but `overrides()` reads the records *after*
|
|
108
|
+
the first, so without this the change would be invisible to it. A `/effort`
|
|
109
|
+
chosen before the first message would then apply to no model call at all, which
|
|
110
|
+
is indistinguishable from `/effort` doing nothing.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
self._file: TextIO | None = None
|
|
114
|
+
if not defer:
|
|
115
|
+
self._start()
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def started(self) -> bool:
|
|
119
|
+
"""Whether this session has a file. False only for a deferred one that nobody
|
|
120
|
+
has said anything to."""
|
|
121
|
+
return self._file is not None
|
|
122
|
+
|
|
123
|
+
def _start(self) -> None:
|
|
124
|
+
"""Open the file and flush anything held back. Idempotent by the caller's check.
|
|
125
|
+
|
|
126
|
+
Line-buffered and held open: a turn with twenty tool calls produces sixty
|
|
127
|
+
events, and open/close per event is not worth paying.
|
|
128
|
+
"""
|
|
129
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
self._file = self.path.open("a", encoding="utf-8", buffering=1)
|
|
131
|
+
held, self._held = self._held, []
|
|
132
|
+
for record in held:
|
|
133
|
+
self._write(record)
|
|
134
|
+
|
|
135
|
+
def _write(self, record: dict[str, Any]) -> None:
|
|
136
|
+
assert self._file is not None
|
|
137
|
+
self._file.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
|
|
138
|
+
|
|
139
|
+
# ---- construction ----
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def create(
|
|
143
|
+
cls,
|
|
144
|
+
*,
|
|
145
|
+
cwd: str | Path | None = None,
|
|
146
|
+
role: str = "",
|
|
147
|
+
model: str = "",
|
|
148
|
+
directory: str | Path | None = None,
|
|
149
|
+
defer: bool = False,
|
|
150
|
+
**meta: Any,
|
|
151
|
+
) -> "Session":
|
|
152
|
+
"""Start a new session with the `meta` record that `list()` reads back.
|
|
153
|
+
|
|
154
|
+
`defer=True` keeps that record in memory and writes no file until the first
|
|
155
|
+
message. The id is real either way — the daemon registers it, a client is
|
|
156
|
+
handed it — so nothing downstream has to know which kind it got.
|
|
157
|
+
"""
|
|
158
|
+
root = sessions_dir(directory)
|
|
159
|
+
session = cls(root / f"{new_id()}.jsonl", defer=defer)
|
|
160
|
+
session.append(
|
|
161
|
+
type="meta",
|
|
162
|
+
id=session.id,
|
|
163
|
+
cwd=str(Path(cwd).expanduser() if cwd else Path.cwd()),
|
|
164
|
+
role=role,
|
|
165
|
+
model=model,
|
|
166
|
+
**meta,
|
|
167
|
+
)
|
|
168
|
+
return session
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def resume(cls, session_id: str, *, directory: str | Path | None = None) -> "Session":
|
|
172
|
+
"""Reopen an existing session and keep appending to it."""
|
|
173
|
+
path = sessions_dir(directory) / f"{session_id}.jsonl"
|
|
174
|
+
if not path.is_file():
|
|
175
|
+
raise FileNotFoundError(f"No session {session_id!r} in {path.parent}.")
|
|
176
|
+
return cls(path)
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def list(
|
|
180
|
+
cls, limit: int = 20, *, directory: str | Path | None = None
|
|
181
|
+
) -> builtins.list[dict[str, Any]]:
|
|
182
|
+
"""The `meta` record of each session, newest first.
|
|
183
|
+
|
|
184
|
+
A directory listing and one line per file. No index, no SQLite: ULID filenames
|
|
185
|
+
already sort by time, so the newest N are the last N names.
|
|
186
|
+
"""
|
|
187
|
+
root = sessions_dir(directory)
|
|
188
|
+
if not root.is_dir():
|
|
189
|
+
return []
|
|
190
|
+
summaries: builtins.list[dict[str, Any]] = []
|
|
191
|
+
for path in sorted(root.glob("*.jsonl"), reverse=True)[:limit]:
|
|
192
|
+
meta = _first_meta(path)
|
|
193
|
+
meta.setdefault("id", path.stem)
|
|
194
|
+
meta["path"] = str(path)
|
|
195
|
+
meta["summary"] = _summary(path)
|
|
196
|
+
summaries.append(meta)
|
|
197
|
+
return summaries
|
|
198
|
+
|
|
199
|
+
def child(self, name: str) -> "Session":
|
|
200
|
+
"""A separate session file for a sub-agent, linked back to this one.
|
|
201
|
+
|
|
202
|
+
A separate *file*: interleaving a child's tool calls into the parent's
|
|
203
|
+
transcript would make `messages()` produce a conversation neither agent had.
|
|
204
|
+
The link is two fields in the child's `meta` — `parent` and `agent_name` —
|
|
205
|
+
enough to reassemble the tree.
|
|
206
|
+
|
|
207
|
+
From `meta()` and deliberately **not** `overrides()`: a `/model` override is a
|
|
208
|
+
statement about this conversation, and inheriting it would pin
|
|
209
|
+
`task(difficulty="low")` to the expensive model and make tiers decorative.
|
|
210
|
+
|
|
211
|
+
Deferred, like any other session, so a sub-agent that fails before it says
|
|
212
|
+
anything leaves no file.
|
|
213
|
+
"""
|
|
214
|
+
return Session.create(
|
|
215
|
+
cwd=self.meta().get("cwd"),
|
|
216
|
+
role=self.meta().get("role", ""),
|
|
217
|
+
model=self.meta().get("model", ""),
|
|
218
|
+
directory=self.path.parent,
|
|
219
|
+
defer=True,
|
|
220
|
+
parent=self.id,
|
|
221
|
+
agent_name=name,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# ---- writing ----
|
|
225
|
+
|
|
226
|
+
def append(self, **record: Any) -> dict[str, Any]:
|
|
227
|
+
"""Append one record. Synchronous and flushed — see the module docstring.
|
|
228
|
+
|
|
229
|
+
`type` is required; `ts` is stamped here so no caller has to remember to.
|
|
230
|
+
"""
|
|
231
|
+
if "type" not in record:
|
|
232
|
+
raise ValueError("a session record needs a `type`")
|
|
233
|
+
record.setdefault("ts", datetime.now(timezone.utc).isoformat(timespec="milliseconds"))
|
|
234
|
+
self._records.append(record)
|
|
235
|
+
if self._file is None:
|
|
236
|
+
# A `meta` record does not start a session; something being *said* does.
|
|
237
|
+
# Held in order, so the file still opens with its meta line.
|
|
238
|
+
if record["type"] == "meta":
|
|
239
|
+
self._held.append(record)
|
|
240
|
+
return record
|
|
241
|
+
self._start()
|
|
242
|
+
self._write(record)
|
|
243
|
+
return record
|
|
244
|
+
|
|
245
|
+
def set_meta(self, **fields: Any) -> dict[str, Any]:
|
|
246
|
+
"""Change a session setting — the model, the provider, the reasoning effort.
|
|
247
|
+
|
|
248
|
+
On a session that has started this **appends** another `meta` record: rule 4,
|
|
249
|
+
nothing is rewritten, and the transcript says when the change happened.
|
|
250
|
+
|
|
251
|
+
On one that has not, it merges into the pending meta instead. Nothing has been
|
|
252
|
+
written, so there is nothing to rewrite — and a file whose first two lines are
|
|
253
|
+
both `meta` is a file that did not need the first one.
|
|
254
|
+
"""
|
|
255
|
+
if not self.started:
|
|
256
|
+
pending = next((r for r in self._records if r.get("type") == "meta"), None)
|
|
257
|
+
if pending is not None:
|
|
258
|
+
pending.update(fields)
|
|
259
|
+
self._chosen.update(fields)
|
|
260
|
+
return pending
|
|
261
|
+
return self.append(type="meta", **fields)
|
|
262
|
+
|
|
263
|
+
# ---- reading ----
|
|
264
|
+
|
|
265
|
+
def meta(self) -> dict[str, Any]:
|
|
266
|
+
"""The **first** `meta` record: what this session started as.
|
|
267
|
+
|
|
268
|
+
Not merged with `overrides()`. A caller that wants the effective settings asks
|
|
269
|
+
for both and says which is which — `SessionRunner.describe()` does exactly
|
|
270
|
+
that — because "the model it started with" and "the model it is on now" are
|
|
271
|
+
different questions and a merged dict can only answer one of them.
|
|
272
|
+
"""
|
|
273
|
+
records = self._meta_records()
|
|
274
|
+
return records[0] if records else {}
|
|
275
|
+
|
|
276
|
+
def overrides(self) -> dict[str, Any]:
|
|
277
|
+
"""Every `meta` record **after** the first, merged, later wins.
|
|
278
|
+
|
|
279
|
+
The split is the whole mechanism: `meta()` is what the session started as and
|
|
280
|
+
what `list()` shows, `overrides()` is what has been changed since. The agent
|
|
281
|
+
reads this before each model call, so a `/model` lands on the next call rather
|
|
282
|
+
than the next session.
|
|
283
|
+
|
|
284
|
+
Bookkeeping keys are dropped (`META_BOOKKEEPING`) so the result is settings
|
|
285
|
+
only and can go straight to the gateway.
|
|
286
|
+
|
|
287
|
+
Settings chosen before the session had a file count too, even though they were
|
|
288
|
+
folded into the first record rather than appended after it — they are still
|
|
289
|
+
something the user changed, and the alternative is a `/effort` that silently
|
|
290
|
+
applies to nothing because it was chosen one message too early.
|
|
291
|
+
"""
|
|
292
|
+
merged: dict[str, Any] = dict(self._chosen)
|
|
293
|
+
for record in self._meta_records()[1:]:
|
|
294
|
+
merged.update(
|
|
295
|
+
{key: value for key, value in record.items() if key not in META_BOOKKEEPING}
|
|
296
|
+
)
|
|
297
|
+
return merged
|
|
298
|
+
|
|
299
|
+
def _meta_records(self) -> builtins.list[dict[str, Any]]:
|
|
300
|
+
return [record for record in self._records if record.get("type") == "meta"]
|
|
301
|
+
|
|
302
|
+
def records(self) -> builtins.list[dict[str, Any]]:
|
|
303
|
+
return list(self._records)
|
|
304
|
+
|
|
305
|
+
def tail(self, count: int = 30) -> builtins.list[dict[str, Any]]:
|
|
306
|
+
"""The last `count` raw records — the supervisor's input."""
|
|
307
|
+
return list(self._records[-count:]) if count > 0 else []
|
|
308
|
+
|
|
309
|
+
def messages(self) -> builtins.list[Message]:
|
|
310
|
+
"""Fold the transcript into what the gateway sends.
|
|
311
|
+
|
|
312
|
+
Three foldings, each demanded by the wire format:
|
|
313
|
+
|
|
314
|
+
* an `assistant` record plus the `tool_call`s after it become **one** assistant
|
|
315
|
+
message of `ToolUseBlock`s — providers reject a tool call not attached to the
|
|
316
|
+
assistant turn that made it;
|
|
317
|
+
* consecutive `tool_result`s become **one** user message of `ToolResultBlock`s,
|
|
318
|
+
because a turn's parallel calls are answered together;
|
|
319
|
+
* `supervisor` and `inbox` become prefixed user messages — neither is a role any
|
|
320
|
+
provider knows, and the model has to be told who is talking.
|
|
321
|
+
"""
|
|
322
|
+
messages: builtins.list[Message] = []
|
|
323
|
+
assistant_blocks: builtins.list[ContentBlock] = []
|
|
324
|
+
result_blocks: builtins.list[ContentBlock] = []
|
|
325
|
+
|
|
326
|
+
def flush_assistant() -> None:
|
|
327
|
+
nonlocal assistant_blocks
|
|
328
|
+
if assistant_blocks:
|
|
329
|
+
messages.append(Message(role="assistant", content=assistant_blocks))
|
|
330
|
+
assistant_blocks = []
|
|
331
|
+
|
|
332
|
+
def flush_results() -> None:
|
|
333
|
+
nonlocal result_blocks
|
|
334
|
+
if result_blocks:
|
|
335
|
+
messages.append(Message(role="user", content=result_blocks))
|
|
336
|
+
result_blocks = []
|
|
337
|
+
|
|
338
|
+
for record in self._records:
|
|
339
|
+
kind = record.get("type")
|
|
340
|
+
if kind not in MODEL_VISIBLE:
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
if kind == "tool_call":
|
|
344
|
+
flush_results()
|
|
345
|
+
assistant_blocks.append(
|
|
346
|
+
ToolUseBlock(
|
|
347
|
+
id=str(record.get("id", "")),
|
|
348
|
+
name=str(record.get("name", "")),
|
|
349
|
+
input=dict(record.get("arguments") or {}),
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
if kind == "tool_result":
|
|
355
|
+
flush_assistant()
|
|
356
|
+
result_blocks.append(
|
|
357
|
+
ToolResultBlock(
|
|
358
|
+
tool_use_id=str(record.get("id", "")),
|
|
359
|
+
content=str(record.get("content", "")),
|
|
360
|
+
is_error=bool(record.get("is_error", False)),
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
flush_assistant()
|
|
366
|
+
flush_results()
|
|
367
|
+
if kind == "assistant":
|
|
368
|
+
text = str(record.get("content", ""))
|
|
369
|
+
if text:
|
|
370
|
+
assistant_blocks.append(TextBlock(text=text))
|
|
371
|
+
continue
|
|
372
|
+
messages.append(Message(role="user", content=_render_user(record)))
|
|
373
|
+
|
|
374
|
+
flush_assistant()
|
|
375
|
+
flush_results()
|
|
376
|
+
return messages
|
|
377
|
+
|
|
378
|
+
# ---- lifecycle ----
|
|
379
|
+
|
|
380
|
+
def close(self) -> None:
|
|
381
|
+
"""Close the file if there is one. A deferred session nobody used has none, and
|
|
382
|
+
closing it must leave the directory as it found it."""
|
|
383
|
+
if self._file is not None and not self._file.closed:
|
|
384
|
+
self._file.close()
|
|
385
|
+
|
|
386
|
+
def __enter__(self) -> "Session":
|
|
387
|
+
return self
|
|
388
|
+
|
|
389
|
+
def __exit__(self, *_exc_info: object) -> None:
|
|
390
|
+
self.close()
|
|
391
|
+
|
|
392
|
+
def __repr__(self) -> str:
|
|
393
|
+
return f"Session({self.id}, {len(self._records)} records)"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _render_user(record: dict[str, Any]) -> str:
|
|
397
|
+
"""A `user`, `supervisor` or `inbox` record as text the model can act on.
|
|
398
|
+
|
|
399
|
+
The prefixes matter: a nudge that reads like the user asking for something gets
|
|
400
|
+
treated as a new instruction, while one that names its source gets treated as the
|
|
401
|
+
correction it is. An inbox message carries `refs`, so the paths are the payload.
|
|
402
|
+
"""
|
|
403
|
+
kind = record.get("type")
|
|
404
|
+
if kind == "supervisor":
|
|
405
|
+
return f"[supervisor] {record.get('content', '')}"
|
|
406
|
+
if kind == "inbox":
|
|
407
|
+
refs = record.get("refs") or []
|
|
408
|
+
lines = [f"[message from {record.get('from', 'unknown')}] {record.get('subject', '')}"]
|
|
409
|
+
if record.get("body"):
|
|
410
|
+
lines.append(str(record["body"]))
|
|
411
|
+
if refs:
|
|
412
|
+
lines.append("Refs: " + ", ".join(str(ref) for ref in refs))
|
|
413
|
+
return "\n".join(lines)
|
|
414
|
+
return str(record.get("content", ""))
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def read_records(path: Path) -> Iterator[dict[str, Any]]:
|
|
418
|
+
"""Every well-formed record in a session file.
|
|
419
|
+
|
|
420
|
+
A truncated final line (the process died mid-write) is skipped, not raised on: a
|
|
421
|
+
session you cannot open is one whose trajectory you cannot read, which is exactly
|
|
422
|
+
when you most need to.
|
|
423
|
+
"""
|
|
424
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
425
|
+
for line in handle:
|
|
426
|
+
line = line.strip()
|
|
427
|
+
if not line:
|
|
428
|
+
continue
|
|
429
|
+
try:
|
|
430
|
+
record = json.loads(line)
|
|
431
|
+
except json.JSONDecodeError:
|
|
432
|
+
continue
|
|
433
|
+
if isinstance(record, dict):
|
|
434
|
+
yield record
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
SUMMARY_SCAN = 20
|
|
438
|
+
"""How far into a file to look for the first thing that was said. It is the second
|
|
439
|
+
line in every session there has ever been; the bound is there so a corrupt file cannot
|
|
440
|
+
turn a directory listing into a full read."""
|
|
441
|
+
|
|
442
|
+
SUMMARY_CHARS = 72
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _summary(path: Path) -> str:
|
|
446
|
+
"""The first thing the user said, as one short line.
|
|
447
|
+
|
|
448
|
+
What makes a session list usable: a column of ULIDs and timestamps does not tell
|
|
449
|
+
you which conversation was which, and the opening message always does.
|
|
450
|
+
"""
|
|
451
|
+
for index, record in enumerate(read_records(path)):
|
|
452
|
+
if index >= SUMMARY_SCAN:
|
|
453
|
+
break
|
|
454
|
+
if record.get("type") == "user":
|
|
455
|
+
text = " ".join(str(record.get("content", "")).split())
|
|
456
|
+
return text if len(text) <= SUMMARY_CHARS else text[: SUMMARY_CHARS - 1] + "…"
|
|
457
|
+
return ""
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _first_meta(path: Path) -> dict[str, Any]:
|
|
461
|
+
for record in read_records(path):
|
|
462
|
+
if record.get("type") == "meta":
|
|
463
|
+
return record
|
|
464
|
+
break # meta is always first; anything else means there is none
|
|
465
|
+
return {}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def prune(keep: int = 100, *, directory: str | Path | None = None) -> list[Path]:
|
|
469
|
+
"""Delete all but the newest `keep` session files. Returns what was removed."""
|
|
470
|
+
root = sessions_dir(directory)
|
|
471
|
+
if not root.is_dir():
|
|
472
|
+
return []
|
|
473
|
+
stale: Sequence[Path] = sorted(root.glob("*.jsonl"), reverse=True)[keep:]
|
|
474
|
+
removed = []
|
|
475
|
+
for path in stale:
|
|
476
|
+
try:
|
|
477
|
+
path.unlink()
|
|
478
|
+
except OSError:
|
|
479
|
+
continue
|
|
480
|
+
removed.append(path)
|
|
481
|
+
return removed
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
__all__ = [
|
|
485
|
+
"DEFAULT_SESSION_DIR",
|
|
486
|
+
"META_BOOKKEEPING",
|
|
487
|
+
"MODEL_VISIBLE",
|
|
488
|
+
"Session",
|
|
489
|
+
"new_id",
|
|
490
|
+
"prune",
|
|
491
|
+
"read_records",
|
|
492
|
+
"sessions_dir",
|
|
493
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`core/team/` — one agent per container, coordinating through a shared volume.
|
|
3
|
+
|
|
4
|
+
Two files, and the split is the dependency arrow:
|
|
5
|
+
|
|
6
|
+
* `mailbox.py` — a member is a card in `/team/members/`, a message is a JSON file in
|
|
7
|
+
`/team/inbox/<role>/`. Imports nothing from the harness.
|
|
8
|
+
* `tools.py` — `find_teammate` and `send_team_message`, factories closing over one
|
|
9
|
+
role's mailbox. Registered by `Agent.enable_team()`, the same shape `task` uses.
|
|
10
|
+
|
|
11
|
+
Roles are not here: a role is the agent's own `config.toml`, because a new role must be
|
|
12
|
+
a new file rather than a code change.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from stcode.core.team.mailbox import (
|
|
16
|
+
ARTIFACTS,
|
|
17
|
+
DEFAULT_TEAM_DIR,
|
|
18
|
+
INBOX,
|
|
19
|
+
KNOWLEDGE,
|
|
20
|
+
MEMBERS,
|
|
21
|
+
Mailbox,
|
|
22
|
+
TeamMember,
|
|
23
|
+
TeamMessage,
|
|
24
|
+
)
|
|
25
|
+
from stcode.core.team.tools import make_find_teammate_tool, make_send_team_message_tool
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ARTIFACTS",
|
|
29
|
+
"DEFAULT_TEAM_DIR",
|
|
30
|
+
"INBOX",
|
|
31
|
+
"KNOWLEDGE",
|
|
32
|
+
"MEMBERS",
|
|
33
|
+
"Mailbox",
|
|
34
|
+
"TeamMember",
|
|
35
|
+
"TeamMessage",
|
|
36
|
+
"make_find_teammate_tool",
|
|
37
|
+
"make_send_team_message_tool",
|
|
38
|
+
]
|