co-agent-coder 0.1.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.
- co_agent_coder-0.1.0.dist-info/METADATA +396 -0
- co_agent_coder-0.1.0.dist-info/RECORD +51 -0
- co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
- co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
- co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
- co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
- coagent/__init__.py +84 -0
- coagent/__main__.py +6 -0
- coagent/agent/__init__.py +27 -0
- coagent/agent/budget.py +220 -0
- coagent/agent/loop.py +1102 -0
- coagent/agent/prompts.py +228 -0
- coagent/agent/state.py +181 -0
- coagent/cli.py +351 -0
- coagent/config.py +307 -0
- coagent/context/__init__.py +26 -0
- coagent/context/artifacts.py +431 -0
- coagent/context/repo_map.py +590 -0
- coagent/context/retrieval.py +375 -0
- coagent/context/tokens.py +93 -0
- coagent/evals/__init__.py +0 -0
- coagent/gitx/__init__.py +52 -0
- coagent/gitx/commit.py +91 -0
- coagent/gitx/durable.py +147 -0
- coagent/gitx/git.py +351 -0
- coagent/gitx/lock.py +157 -0
- coagent/gitx/log.py +85 -0
- coagent/gitx/resume.py +199 -0
- coagent/gitx/session.py +233 -0
- coagent/llm/__init__.py +83 -0
- coagent/llm/base.py +235 -0
- coagent/llm/grammar.py +306 -0
- coagent/llm/registry.py +147 -0
- coagent/llm/transformers_backend.py +326 -0
- coagent/py.typed +0 -0
- coagent/safety/__init__.py +24 -0
- coagent/safety/denylist.py +146 -0
- coagent/safety/policy.py +219 -0
- coagent/safety/redact.py +146 -0
- coagent/templates/config.toml +104 -0
- coagent/tools/__init__.py +155 -0
- coagent/tools/artifacts.py +307 -0
- coagent/tools/base.py +226 -0
- coagent/tools/fs.py +507 -0
- coagent/tools/notebooks.py +875 -0
- coagent/tools/pyt.py +240 -0
- coagent/tools/shell.py +558 -0
- coagent/ui/__init__.py +50 -0
- coagent/ui/gradio_app.py +353 -0
- coagent/ui/notebook.py +342 -0
- coagent/ui/render.py +422 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""BM25 retrieval over function-sized chunks (DESIGN §5.3, PRD FR-CTX-2).
|
|
2
|
+
|
|
3
|
+
What this is
|
|
4
|
+
------------
|
|
5
|
+
Given a query (the task, a failing test name, a symbol the model asked about),
|
|
6
|
+
return the few source excerpts most likely to be relevant, formatted as citable
|
|
7
|
+
``file:start-end`` blocks that fit the retrieved-excerpts slot of the §5.1
|
|
8
|
+
budget.
|
|
9
|
+
|
|
10
|
+
Why BM25 and not embeddings
|
|
11
|
+
---------------------------
|
|
12
|
+
On 2 vCPU beside a GPU, an embedding index is a memory and latency tax for a
|
|
13
|
+
ranking gain BM25 mostly covers in a repo of this size. ``all-MiniLM-L6-v2`` is
|
|
14
|
+
an optional future upgrade, not a dependency and not part of v1.
|
|
15
|
+
|
|
16
|
+
Why chunks come from the repo map
|
|
17
|
+
---------------------------------
|
|
18
|
+
The repo map already parses every file into ``Signature`` objects carrying line
|
|
19
|
+
spans. Re-parsing for retrieval would double the only real cost in the context
|
|
20
|
+
layer, so :class:`Retriever` consumes a :class:`RepoMap` and derives chunks
|
|
21
|
+
from it. Two consequences worth knowing:
|
|
22
|
+
|
|
23
|
+
* A chunk is a *function-sized* unit with its signature attached, so the model
|
|
24
|
+
always sees the name and parameters of what it is being shown.
|
|
25
|
+
* Files the map skipped (vendored, generated, non-Python) are not retrievable.
|
|
26
|
+
That is intentional: they are not editable either.
|
|
27
|
+
|
|
28
|
+
Ranking is BM25 plus a symbol-graph adjacency bonus: if a chunk's symbols are
|
|
29
|
+
defined in, or referenced from, a file already in context, that chunk gets
|
|
30
|
+
pushed up. This is the "callers/callees of the files already in context" part of
|
|
31
|
+
§5.3, and it is what lets the agent pull a helper into view without being told.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import math
|
|
37
|
+
import re
|
|
38
|
+
from collections import Counter
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
|
|
41
|
+
from .repo_map import RepoMap
|
|
42
|
+
from .tokens import estimate_tokens
|
|
43
|
+
|
|
44
|
+
__all__ = ["Chunk", "RetrievalConfig", "Retriever", "Recalled"]
|
|
45
|
+
|
|
46
|
+
#: §5.1 gives retrieved excerpts 3000 tokens, BM25, 5 chunks max.
|
|
47
|
+
DEFAULT_MAX_TOKENS = 3000
|
|
48
|
+
DEFAULT_MAX_CHUNKS = 5
|
|
49
|
+
|
|
50
|
+
#: A chunk longer than this is a class or a huge function. We cap the body so a
|
|
51
|
+
#: single 800-line method cannot consume the whole excerpt slot.
|
|
52
|
+
MAX_CHUNK_LINES = 80
|
|
53
|
+
|
|
54
|
+
#: BM25 parameters. k1 controls term-frequency saturation and b controls
|
|
55
|
+
#: length normalisation. 1.2 / 0.75 are the standard defaults and are fine for
|
|
56
|
+
#: source code, which has short documents and repetitive vocabulary.
|
|
57
|
+
BM25_K1 = 1.2
|
|
58
|
+
BM25_B = 0.75
|
|
59
|
+
|
|
60
|
+
#: Identifier-ish tokens. Code is not English: we keep snake_case and camelCase
|
|
61
|
+
#: whole, then split them, so `load_csv` matches a query for "csv loader".
|
|
62
|
+
_WORD_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
63
|
+
_CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
|
|
64
|
+
|
|
65
|
+
#: Words that appear in almost every file and carry no signal. Kept small on
|
|
66
|
+
#: purpose: an aggressive stoplist on code drops real identifiers.
|
|
67
|
+
_STOPWORDS = frozenset(
|
|
68
|
+
{
|
|
69
|
+
"a", "an", "the", "and", "or", "if", "in", "is", "it", "of", "to", "for",
|
|
70
|
+
"self", "cls", "def", "class", "return", "import", "from", "as", "with",
|
|
71
|
+
"not", "none", "true", "false", "this", "that", "be", "on", "at", "by",
|
|
72
|
+
"pass", "else", "elif", "while", "try", "except", "raise", "lambda",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _tokenize(text: str) -> list[str]:
|
|
78
|
+
"""Split source into lowercase terms, exploding snake_case and camelCase."""
|
|
79
|
+
out: list[str] = []
|
|
80
|
+
for word in _WORD_RE.findall(text):
|
|
81
|
+
low = word.lower()
|
|
82
|
+
if low not in _STOPWORDS and len(low) > 1:
|
|
83
|
+
out.append(low)
|
|
84
|
+
# `load_csv` -> load, csv; `loadCsv` -> load, csv. The joined form is
|
|
85
|
+
# kept above, so both an exact and a split query match.
|
|
86
|
+
if "_" in word or any(c.isupper() for c in word[1:]):
|
|
87
|
+
parts = [p for p in re.split(r"_+", word) if p]
|
|
88
|
+
expanded: list[str] = []
|
|
89
|
+
for part in parts:
|
|
90
|
+
expanded.extend(_CAMEL_RE.findall(part) or [part])
|
|
91
|
+
for part in expanded:
|
|
92
|
+
p = part.lower()
|
|
93
|
+
if p not in _STOPWORDS and len(p) > 1:
|
|
94
|
+
out.append(p)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Chunk:
|
|
100
|
+
"""One retrievable unit: a function, method, class head, or module prologue."""
|
|
101
|
+
|
|
102
|
+
rel: str
|
|
103
|
+
start_line: int
|
|
104
|
+
end_line: int
|
|
105
|
+
text: str
|
|
106
|
+
#: Signature-derived name, used for adjacency and for the excerpt header.
|
|
107
|
+
symbol: str | None = None
|
|
108
|
+
kind: str = "def"
|
|
109
|
+
#: Terms for BM25, computed once per chunk rather than per query.
|
|
110
|
+
terms: list[str] = field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def locator(self) -> str:
|
|
114
|
+
return f"{self.rel}:{self.start_line}-{self.end_line}"
|
|
115
|
+
|
|
116
|
+
def header(self) -> str:
|
|
117
|
+
name = f" {self.symbol}" if self.symbol else ""
|
|
118
|
+
return f"--- {self.locator}{name} ---"
|
|
119
|
+
|
|
120
|
+
def render(self) -> str:
|
|
121
|
+
return f"{self.header()}\n{self.text}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class RetrievalConfig:
|
|
126
|
+
max_tokens: int = DEFAULT_MAX_TOKENS
|
|
127
|
+
max_chunks: int = DEFAULT_MAX_CHUNKS
|
|
128
|
+
max_chunk_lines: int = MAX_CHUNK_LINES
|
|
129
|
+
#: Weight of the adjacency bonus relative to a normalised BM25 score.
|
|
130
|
+
adjacency_weight: float = 0.35
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class Recalled:
|
|
135
|
+
"""A scored chunk, returned so callers can log or re-rank."""
|
|
136
|
+
|
|
137
|
+
chunk: Chunk
|
|
138
|
+
score: float
|
|
139
|
+
bm25: float
|
|
140
|
+
adjacency: float
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def locator(self) -> str:
|
|
144
|
+
return self.chunk.locator
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Retriever:
|
|
148
|
+
"""BM25 + symbol adjacency over chunks derived from a :class:`RepoMap`.
|
|
149
|
+
|
|
150
|
+
Usage from the loop::
|
|
151
|
+
|
|
152
|
+
retriever = Retriever(repo_map)
|
|
153
|
+
excerpts = retriever.render("chunked csv loader dtype", in_context={"src/frame.py"})
|
|
154
|
+
|
|
155
|
+
The index is built once per ``RepoMap`` and reused across queries; a query
|
|
156
|
+
costs one pass over the chunk list.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
repo_map: RepoMap,
|
|
162
|
+
*,
|
|
163
|
+
config: RetrievalConfig | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
self.repo_map = repo_map
|
|
166
|
+
self.config = config or RetrievalConfig()
|
|
167
|
+
self._chunks: list[Chunk] | None = None
|
|
168
|
+
self._df: Counter[str] = Counter()
|
|
169
|
+
self._avgdl: float = 0.0
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------ index
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def avgdl(self) -> float:
|
|
175
|
+
"""Mean chunk length, after ensuring the index exists."""
|
|
176
|
+
self.chunks()
|
|
177
|
+
return self._avgdl
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def doc_freq(self) -> Counter[str]:
|
|
181
|
+
"""Document frequencies, after ensuring the index exists."""
|
|
182
|
+
self.chunks()
|
|
183
|
+
return self._df
|
|
184
|
+
|
|
185
|
+
def chunks(self) -> list[Chunk]:
|
|
186
|
+
"""Build (once) and return the chunk corpus."""
|
|
187
|
+
if self._chunks is None:
|
|
188
|
+
self._chunks = self._build_chunks()
|
|
189
|
+
self._df = Counter()
|
|
190
|
+
for chunk in self._chunks:
|
|
191
|
+
for term in set(chunk.terms):
|
|
192
|
+
self._df[term] += 1
|
|
193
|
+
total = sum(len(c.terms) for c in self._chunks)
|
|
194
|
+
self._avgdl = total / len(self._chunks) if self._chunks else 0.0
|
|
195
|
+
return self._chunks
|
|
196
|
+
|
|
197
|
+
def _build_chunks(self) -> list[Chunk]:
|
|
198
|
+
"""Cut every file into function-sized chunks using repo map spans."""
|
|
199
|
+
chunks: list[Chunk] = []
|
|
200
|
+
for fm in self.repo_map.files():
|
|
201
|
+
try:
|
|
202
|
+
source = (self.repo_map.root / fm.rel).read_text(encoding="utf-8", errors="replace")
|
|
203
|
+
except OSError:
|
|
204
|
+
continue
|
|
205
|
+
lines = source.splitlines()
|
|
206
|
+
if not lines:
|
|
207
|
+
continue
|
|
208
|
+
spans = [(s.start_line, s.end_line, s) for s in fm.signatures if s.start_line > 0]
|
|
209
|
+
spans.sort()
|
|
210
|
+
|
|
211
|
+
# The module prologue — imports and module docstring — is often the
|
|
212
|
+
# most informative part of a file about what it depends on.
|
|
213
|
+
first = spans[0][0] if spans else len(lines) + 1
|
|
214
|
+
if first > 1 and first <= 40:
|
|
215
|
+
chunks.append(
|
|
216
|
+
_make_chunk(fm.rel, 1, first - 1, lines, "<module>", "module", self.config)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
for start, end, sig in spans:
|
|
220
|
+
if sig.kind == "const":
|
|
221
|
+
continue # a bare `X = ...` line is not worth an excerpt slot
|
|
222
|
+
chunks.append(
|
|
223
|
+
_make_chunk(fm.rel, start, end or start, lines, sig.name, sig.kind, self.config)
|
|
224
|
+
)
|
|
225
|
+
return chunks
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------ query
|
|
228
|
+
|
|
229
|
+
def search(
|
|
230
|
+
self,
|
|
231
|
+
query: str,
|
|
232
|
+
*,
|
|
233
|
+
in_context: set[str] | None = None,
|
|
234
|
+
limit: int | None = None,
|
|
235
|
+
) -> list[Recalled]:
|
|
236
|
+
"""Rank chunks against ``query``, boosted by adjacency to ``in_context``.
|
|
237
|
+
|
|
238
|
+
``in_context`` holds repo-relative paths already shown to the model
|
|
239
|
+
(files it read, or the file the task names). Chunks defined in those
|
|
240
|
+
files are excluded — re-showing them wastes the slot — while chunks that
|
|
241
|
+
*reference* their symbols get the adjacency bonus.
|
|
242
|
+
"""
|
|
243
|
+
chunks = self.chunks()
|
|
244
|
+
if not chunks:
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
terms = _tokenize(query)
|
|
248
|
+
if not terms:
|
|
249
|
+
return []
|
|
250
|
+
|
|
251
|
+
context = {_norm(p) for p in (in_context or set())}
|
|
252
|
+
context_symbols: set[str] = set()
|
|
253
|
+
for chunk in chunks:
|
|
254
|
+
if chunk.rel in context and chunk.symbol and chunk.symbol != "<module>":
|
|
255
|
+
context_symbols.add(chunk.symbol.lower())
|
|
256
|
+
|
|
257
|
+
n = len(chunks)
|
|
258
|
+
scores: list[Recalled] = []
|
|
259
|
+
for chunk in chunks:
|
|
260
|
+
if chunk.rel in context:
|
|
261
|
+
continue # already on screen
|
|
262
|
+
bm25 = self._bm25(terms, chunk, n)
|
|
263
|
+
adjacency = self._adjacency(chunk, context, context_symbols)
|
|
264
|
+
total = bm25 + self.config.adjacency_weight * adjacency
|
|
265
|
+
if total > 0:
|
|
266
|
+
scores.append(Recalled(chunk=chunk, score=total, bm25=bm25, adjacency=adjacency))
|
|
267
|
+
|
|
268
|
+
# Deterministic order: score, then locator. Without the tiebreak a
|
|
269
|
+
# re-query can shuffle equal-scoring chunks and invalidate a resumed
|
|
270
|
+
# session's cached context.
|
|
271
|
+
scores.sort(key=lambda r: (-r.score, r.chunk.rel, r.chunk.start_line))
|
|
272
|
+
cap = limit if limit is not None else self.config.max_chunks
|
|
273
|
+
return scores[:cap]
|
|
274
|
+
|
|
275
|
+
def _bm25(self, terms: list[str], chunk: Chunk, n: int) -> float:
|
|
276
|
+
"""Okapi BM25, computed per-query (the corpus is small enough)."""
|
|
277
|
+
if not self._avgdl:
|
|
278
|
+
return 0.0
|
|
279
|
+
tf = Counter(chunk.terms)
|
|
280
|
+
dl = len(chunk.terms)
|
|
281
|
+
score = 0.0
|
|
282
|
+
for term in set(terms):
|
|
283
|
+
freq = tf.get(term, 0)
|
|
284
|
+
if not freq:
|
|
285
|
+
continue
|
|
286
|
+
df = self._df.get(term, 0)
|
|
287
|
+
# Standard BM25 IDF with the +1 inside the log so a term present in
|
|
288
|
+
# every document scores ~0 rather than going negative.
|
|
289
|
+
idf = math.log(1 + (n - df + 0.5) / (df + 0.5))
|
|
290
|
+
denom = freq + BM25_K1 * (1 - BM25_B + BM25_B * dl / self._avgdl)
|
|
291
|
+
score += idf * (freq * (BM25_K1 + 1)) / denom
|
|
292
|
+
return score
|
|
293
|
+
|
|
294
|
+
def _adjacency(self, chunk: Chunk, context: set[str], context_symbols: set[str]) -> float:
|
|
295
|
+
"""Symbol-graph bonus: does this chunk touch what is already in context?
|
|
296
|
+
|
|
297
|
+
Two edges count — the chunk defines a symbol used in a context file, or
|
|
298
|
+
the chunk references a symbol defined in one. Both are 1.0; the caller
|
|
299
|
+
scales the result with ``adjacency_weight``.
|
|
300
|
+
"""
|
|
301
|
+
if not context and not context_symbols:
|
|
302
|
+
return 0.0
|
|
303
|
+
chunk_terms = set(chunk.terms)
|
|
304
|
+
if not chunk_terms:
|
|
305
|
+
return 0.0
|
|
306
|
+
# Import of a module already in context, e.g. "from .frame import ...".
|
|
307
|
+
for rel in context:
|
|
308
|
+
stem = rel.rsplit("/", 1)[-1].rsplit(".", 1)[0].lower()
|
|
309
|
+
if len(stem) > 2 and stem in chunk_terms:
|
|
310
|
+
return 1.0
|
|
311
|
+
if chunk.symbol and chunk.symbol.lower() in context_symbols:
|
|
312
|
+
return 1.0
|
|
313
|
+
if context_symbols & chunk_terms:
|
|
314
|
+
return 1.0
|
|
315
|
+
return 0.0
|
|
316
|
+
|
|
317
|
+
# ----------------------------------------------------------------- render
|
|
318
|
+
|
|
319
|
+
def render(
|
|
320
|
+
self,
|
|
321
|
+
query: str,
|
|
322
|
+
*,
|
|
323
|
+
in_context: set[str] | None = None,
|
|
324
|
+
max_tokens: int | None = None,
|
|
325
|
+
) -> str:
|
|
326
|
+
"""Return the excerpts for ``query``, packed to the §5.1 slot.
|
|
327
|
+
|
|
328
|
+
Chunks are added whole until the budget is hit. A chunk that does not fit
|
|
329
|
+
is skipped rather than truncated: half a function is worse than a
|
|
330
|
+
different whole one, and the excerpt header would lie about the range.
|
|
331
|
+
"""
|
|
332
|
+
budget = self.config.max_tokens if max_tokens is None else max_tokens
|
|
333
|
+
recalled = self.search(query, in_context=in_context)
|
|
334
|
+
if not recalled:
|
|
335
|
+
return ""
|
|
336
|
+
|
|
337
|
+
out: list[str] = []
|
|
338
|
+
used = 0
|
|
339
|
+
for hit in recalled:
|
|
340
|
+
block = hit.chunk.render()
|
|
341
|
+
cost = estimate_tokens(block)
|
|
342
|
+
if used + cost > budget:
|
|
343
|
+
continue
|
|
344
|
+
out.append(block)
|
|
345
|
+
used += cost
|
|
346
|
+
if len(out) >= self.config.max_chunks:
|
|
347
|
+
break
|
|
348
|
+
return "\n\n".join(out)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _make_chunk(
|
|
352
|
+
rel: str,
|
|
353
|
+
start: int,
|
|
354
|
+
end: int,
|
|
355
|
+
lines: list[str],
|
|
356
|
+
symbol: str | None,
|
|
357
|
+
kind: str,
|
|
358
|
+
config: RetrievalConfig,
|
|
359
|
+
) -> Chunk:
|
|
360
|
+
"""Slice a chunk, capping the body so one huge function cannot hog the slot."""
|
|
361
|
+
end = min(end, start + config.max_chunk_lines - 1, len(lines))
|
|
362
|
+
body = "\n".join(lines[start - 1 : end])
|
|
363
|
+
return Chunk(
|
|
364
|
+
rel=rel,
|
|
365
|
+
start_line=start,
|
|
366
|
+
end_line=end,
|
|
367
|
+
text=body,
|
|
368
|
+
symbol=symbol,
|
|
369
|
+
kind=kind,
|
|
370
|
+
terms=_tokenize(body),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _norm(path: str) -> str:
|
|
375
|
+
return path.replace("\\", "/").lstrip("./")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Token accounting for the context ledger (DESIGN §5.1).
|
|
2
|
+
|
|
3
|
+
Estimating tokens must be **cheap and deterministic**, not exact. The loop
|
|
4
|
+
makes dozens of these decisions per task and there is no tokenizer available on
|
|
5
|
+
the CPU path — the model may not even be loaded yet. A ~4 chars/token heuristic
|
|
6
|
+
is within 10–15% for source code and, crucially, it is *monotone*: if the
|
|
7
|
+
estimate says a block fits, the real block is very unlikely to blow the window.
|
|
8
|
+
|
|
9
|
+
The budget here is the repo map's slot only (2000 tokens per §5.1). Everything
|
|
10
|
+
that packs text into a fixed number of tokens goes through :func:`fit`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__all__ = ["estimate_tokens", "fit", "truncate_middle"]
|
|
16
|
+
|
|
17
|
+
#: Mean characters per token for Python/JS source. Code is denser in punctuation
|
|
18
|
+
#: than prose, so it tokenizes closer to 3.5–4 chars than the 4-of-English rule.
|
|
19
|
+
CHARS_PER_TOKEN = 4
|
|
20
|
+
|
|
21
|
+
#: Marker appended when a block was cut; it must not be silently mistaken for
|
|
22
|
+
#: real map content, so it is counted in every fitting decision.
|
|
23
|
+
ELLIPSIS = "…"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def estimate_tokens(text: str) -> int:
|
|
27
|
+
"""Estimate the token cost of ``text``.
|
|
28
|
+
|
|
29
|
+
Counts characters, not bytes: a non-ASCII identifier costs the same as an
|
|
30
|
+
ASCII one under a BPE tokenizer for the blends we target, and using bytes
|
|
31
|
+
would double-count UTF-8 continuation bytes.
|
|
32
|
+
|
|
33
|
+
Always returns at least 1 for non-empty text so that a one-character line
|
|
34
|
+
does not look free.
|
|
35
|
+
"""
|
|
36
|
+
if not text:
|
|
37
|
+
return 0
|
|
38
|
+
return max(1, (len(text) + CHARS_PER_TOKEN - 1) // CHARS_PER_TOKEN)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def truncate_middle(text: str, max_tokens: int) -> str:
|
|
42
|
+
"""Keep the head and tail of ``text``, dropping the middle.
|
|
43
|
+
|
|
44
|
+
Head/tail is deliberate: for a repo map the head is the highest-ranked file
|
|
45
|
+
and the tail is the most recently added context, while the middle is the
|
|
46
|
+
least interesting. That matches the observation-truncation rule in
|
|
47
|
+
DESIGN §5.1 and keeps the two ends, which is where a model looks first.
|
|
48
|
+
"""
|
|
49
|
+
if max_tokens <= 0:
|
|
50
|
+
return ""
|
|
51
|
+
if estimate_tokens(text) <= max_tokens:
|
|
52
|
+
return text
|
|
53
|
+
|
|
54
|
+
max_chars = max_tokens * CHARS_PER_TOKEN
|
|
55
|
+
marker = f"\n{ELLIPSIS} [truncated] {ELLIPSIS}\n"
|
|
56
|
+
if max_chars <= len(marker):
|
|
57
|
+
return text[:max_chars]
|
|
58
|
+
|
|
59
|
+
room = max_chars - len(marker)
|
|
60
|
+
head = int(room * 0.7)
|
|
61
|
+
tail = room - head
|
|
62
|
+
return text[:head] + marker + text[len(text) - tail :]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fit(blocks: list[str], max_tokens: int, *, separator: str = "\n") -> str:
|
|
66
|
+
"""Pack ``blocks`` in order until ``max_tokens`` is reached.
|
|
67
|
+
|
|
68
|
+
Order is the caller's ranking order, so this is a greedy pack (DESIGN §5.2:
|
|
69
|
+
"pack greedily into the 2k budget"). A block that does not fit is not
|
|
70
|
+
allowed to preempt a smaller one later in the list — the ranking decided the
|
|
71
|
+
order and we stop at the first overflow, which keeps the output stable
|
|
72
|
+
across runs instead of reshuffling on every estimate.
|
|
73
|
+
"""
|
|
74
|
+
if max_tokens <= 0 or not blocks:
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
out: list[str] = []
|
|
78
|
+
used = 0
|
|
79
|
+
for block in blocks:
|
|
80
|
+
if not block:
|
|
81
|
+
continue
|
|
82
|
+
cost = estimate_tokens(block) + (1 if out else 0)
|
|
83
|
+
if used + cost > max_tokens:
|
|
84
|
+
break
|
|
85
|
+
out.append(block)
|
|
86
|
+
used += cost
|
|
87
|
+
|
|
88
|
+
if not out:
|
|
89
|
+
# Nothing fit whole: fall back to the first block, truncated. A partial
|
|
90
|
+
# top-ranked file beats an empty map.
|
|
91
|
+
return truncate_middle(blocks[0], max_tokens)
|
|
92
|
+
|
|
93
|
+
return separator.join(out)
|
|
File without changes
|
coagent/gitx/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""gitx — git as the agent's database (DESIGN §3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .commit import TRAILER_ORDER, CommitMeta, build_message, commit_step, parse_trailers
|
|
6
|
+
from .durable import SyncResult, checkpoint, sync_bundle, sync_push
|
|
7
|
+
from .git import GitError, GitRepo, GitResult
|
|
8
|
+
from .lock import LockHeldError, LockInfo, SessionLock
|
|
9
|
+
from .log import LogRecord, SessionLog
|
|
10
|
+
from .resume import Briefing, PlanItem, build_briefing, read_plan_items
|
|
11
|
+
from .session import (
|
|
12
|
+
SESSION_BRANCH_PREFIX,
|
|
13
|
+
DirtyTreeError,
|
|
14
|
+
Session,
|
|
15
|
+
SessionError,
|
|
16
|
+
clone_repo,
|
|
17
|
+
new_session_id,
|
|
18
|
+
open_session,
|
|
19
|
+
short_id,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"SESSION_BRANCH_PREFIX",
|
|
24
|
+
"TRAILER_ORDER",
|
|
25
|
+
"Briefing",
|
|
26
|
+
"CommitMeta",
|
|
27
|
+
"DirtyTreeError",
|
|
28
|
+
"GitError",
|
|
29
|
+
"GitRepo",
|
|
30
|
+
"GitResult",
|
|
31
|
+
"LockHeldError",
|
|
32
|
+
"LockInfo",
|
|
33
|
+
"LogRecord",
|
|
34
|
+
"PlanItem",
|
|
35
|
+
"Session",
|
|
36
|
+
"SessionError",
|
|
37
|
+
"SessionLock",
|
|
38
|
+
"SessionLog",
|
|
39
|
+
"SyncResult",
|
|
40
|
+
"build_briefing",
|
|
41
|
+
"build_message",
|
|
42
|
+
"checkpoint",
|
|
43
|
+
"clone_repo",
|
|
44
|
+
"commit_step",
|
|
45
|
+
"new_session_id",
|
|
46
|
+
"open_session",
|
|
47
|
+
"parse_trailers",
|
|
48
|
+
"read_plan_items",
|
|
49
|
+
"short_id",
|
|
50
|
+
"sync_bundle",
|
|
51
|
+
"sync_push",
|
|
52
|
+
]
|
coagent/gitx/commit.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""The commit protocol (PRD FR-GIT-3, Appendix B).
|
|
2
|
+
|
|
3
|
+
One commit per verified step, with six machine-readable trailers. That gives
|
|
4
|
+
`git log --grep="Coagent-Task: add CSV"`, `git diff <step-6> <step-7>` as replay,
|
|
5
|
+
a blame trail naming the model and step, and `git revert` as a per-action undo.
|
|
6
|
+
|
|
7
|
+
Trailers are written verbatim (no wrapping, no rewriting), and values are
|
|
8
|
+
sanitized to a single line so a newline inside a task description cannot break
|
|
9
|
+
the header block that `--grep` depends on.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from .git import GitRepo, build_commit_message
|
|
17
|
+
|
|
18
|
+
__all__ = ["CommitMeta", "TRAILER_ORDER", "build_message", "commit_step", "parse_trailers"]
|
|
19
|
+
|
|
20
|
+
TRAILER_ORDER: tuple[str, ...] = (
|
|
21
|
+
"Coagent-Session",
|
|
22
|
+
"Coagent-Step",
|
|
23
|
+
"Coagent-Task",
|
|
24
|
+
"Coagent-Verify",
|
|
25
|
+
"Coagent-Model",
|
|
26
|
+
"Coagent-Artifacts",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_MAX_TRAILER_LEN = 200
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _one_line(value: str, *, limit: int = _MAX_TRAILER_LEN) -> str:
|
|
33
|
+
"""Collapse a value onto one line and cap it — trailers are headers."""
|
|
34
|
+
flat = " ".join(str(value).split())
|
|
35
|
+
if len(flat) > limit:
|
|
36
|
+
flat = flat[: limit - 1] + "…"
|
|
37
|
+
return flat
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class CommitMeta:
|
|
42
|
+
"""Everything a step commit records."""
|
|
43
|
+
|
|
44
|
+
session: str
|
|
45
|
+
step: int
|
|
46
|
+
task: str
|
|
47
|
+
verify: str = "not run"
|
|
48
|
+
model: str = "unknown"
|
|
49
|
+
artifacts: list[str] = field(default_factory=list)
|
|
50
|
+
subject: str = ""
|
|
51
|
+
body: str = ""
|
|
52
|
+
|
|
53
|
+
def trailers(self) -> list[tuple[str, str]]:
|
|
54
|
+
values = {
|
|
55
|
+
"Coagent-Session": self.session,
|
|
56
|
+
"Coagent-Step": str(self.step),
|
|
57
|
+
"Coagent-Task": _one_line(self.task or "unspecified"),
|
|
58
|
+
"Coagent-Verify": _one_line(self.verify or "not run"),
|
|
59
|
+
"Coagent-Model": _one_line(self.model or "unknown"),
|
|
60
|
+
"Coagent-Artifacts": _one_line(
|
|
61
|
+
", ".join(self.artifacts) if self.artifacts else "none"
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
return [(key, values[key]) for key in TRAILER_ORDER]
|
|
65
|
+
|
|
66
|
+
def default_subject(self) -> str:
|
|
67
|
+
return self.subject.strip() or _one_line(self.task or "coagent step", limit=72)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_message(meta: CommitMeta) -> str:
|
|
71
|
+
return build_commit_message(meta.default_subject(), meta.body, meta.trailers())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def commit_step(repo: GitRepo, meta: CommitMeta) -> str:
|
|
75
|
+
"""Stage tracked changes and commit them with the full trailer set."""
|
|
76
|
+
repo.add_all_tracked()
|
|
77
|
+
if not repo.has_staged_changes():
|
|
78
|
+
# Nothing changed: committing an empty step would corrupt the step
|
|
79
|
+
# numbering that `git log --grep` relies on.
|
|
80
|
+
raise ValueError("nothing to commit: no tracked changes are staged")
|
|
81
|
+
return repo.commit_with_trailers(meta.default_subject(), meta.body, meta.trailers())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_trailers(message: str) -> dict[str, str]:
|
|
85
|
+
"""Read the coagent trailers back out of a commit message."""
|
|
86
|
+
found: dict[str, str] = {}
|
|
87
|
+
for line in message.splitlines():
|
|
88
|
+
key, sep, value = line.partition(": ")
|
|
89
|
+
if sep and key.startswith("Coagent-"):
|
|
90
|
+
found[key] = value.strip()
|
|
91
|
+
return found
|