Skip to content

Dev - #220

Merged
LinseCed merged 90 commits into
mainfrom
dev
Oct 7, 2026
Merged

Dev#220
LinseCed merged 90 commits into
mainfrom
dev

Conversation

@LinseCed

@LinseCed LinseCed commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

DaniloTatti and others added 30 commits August 29, 2026 09:48
The buddy's persona gains four clauses, each gated on the tool it depends on, so a
hire with no onboarding path meets a mentor that never mentions one:

- the path is the plan, and where the mentor's own suggestion disagrees with it the
  path wins, because a person wrote it;
- a knowledge question is a tutoring moment, not an exam the mentor can shortcut: it
  is not told which answer is correct, and the clause says so, so "I do not have it"
  is the honest answer when a hire asks for it;
- completing a step is asked for, never announced;
- an empty AI-enhanced phase is a subject to talk through, and a step that comes out
  of that goes on the hire's own copy, never on the PM's blueprint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six things a testing session turned up, five of them the mentor's own judgement and one
of them the reason it had no judgement to make.

The one that matters most: "where am I?" had two plausible answers — the path a person
wrote, and the work pool, metrics and ledger that say what is true right now — and which
one it landed on depended on nothing the hire could see. That is the two-systems problem
moved inside one conversation rather than solved, so the routing is now stated: path
questions to the path, always; the work pool only when the path has nothing open or the
current step is asking for real work; metrics and the ledger answer how it is going and
never what comes next. Mounted only where both exist.

Also:
- A LOCKED item is never agreed to, however directly asked. It said yes to a step the
  hire's own page refuses to open.
- Items are named by the number their page prints and linked, so "want to take the
  check?" arrives as something clickable.
- Part of a step is a line of its checklist, not the step.
- A refusal that came back NOT PROPOSED has no button: it had been telling hires to
  click something that was never rendered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two causes, one symptom: the mentor kept telling hires to confirm something that was
never on their screen.

**The search budget.** Only search-only hops consume it — a hop that asks for a backend
tool returns immediately — so a model that searched four times before deciding to act
never got to act: it fell through to the forced final answer, which is generated with no
tools, while the persona in front of it still said "offer `add_path_step`". It took that
at face value. The forced turn is now told plainly that it has no tools and can offer
nothing, and the budget is 6 rather than 4 so a thorough answer still leaves room to act.

**The mechanics were never stated.** The persona said what to offer and never that
offering *is* a tool call. It now says so, that a call missing an argument is refused and
shows no button, and that describing a button is not making one.

Also: an item is written as a linked number in a literal shape (`[#3](/onboarding/...)
"title"`) rather than as a description of one — a small model copies a pattern far more
reliably than it interprets an instruction about formatting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There is no skip action and there should not be one: a skip is a request the
PM decides, made on the step's own page. The persona now says so, so the mentor
neither promises one nor ignores the ask.

Adds a test that a spent search budget sends the model the no-tools notice and
keeps it out of the conversation, and counts complete_task among the tools a
hire without a path never hears about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mentor brings up a step whose checklist is done but that was never finished,
once, with complete_step; and after a wrong answer it teaches first and offers
one refresher step when the gap is bigger than one explanation. The link example
follows the new /onboarding?step= shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces "skipping is not yours to offer" with a clause gated on request_skip:
ask why first, help put the reason into a sentence or two a PM can decide on,
send it in the hire's words, and never promise it will be accepted. A step
waiting on that decision is not pushed, and finishing it is not offered without
saying that it withdraws the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The numbers are the page's count, not an order: an item opens once what it
comes after is done, and several can open at once. Added steps are placed with
waits_on and unlocks rather than dropped at the end. A ready-to-close step is
answered where-they-are first, never button first and never with a lecture.
Narrowing a question's options down counts as hinting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t-scoping-in-onboarding-generators

Add multi-project scoping to onboarding generators (#167)
The persona still carried the old ramp: a mentor guiding a hire "from
their first day to doing real work", Task 0 as an action, and a closing
line that called everything else "the path to" a first contribution.
Onboarding is the path a PM's blueprint prescribes; the buddy tutors
along it.

- Identity reworded around the onboarding, not a first contribution.
- Path clauses come first; the arrival clause follows as setup, not
  as part of the onboarding.
- Routing: "how is my onboarding going" goes to the path; the metrics
  answer how their work is going and never how far along they are.
- claim_task_zero removed from the action tools.

Refs #311

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pus-revision

Use datastore revision for BM25 cache invalidation
LinseCed and others added 26 commits September 29, 2026 16:20
"Answer" alone would read "can you answer this: who is stuck?" as a request
for answer_escalation. The rule now names a hire's question, and says a
question the manager asks is answered directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…epare-changes

Tell the team buddy that preparing a change is acting for the manager
"How do I create a project?" is a question about Sprintstart, not about
the project the team works on, so search_docs has nothing to say about
it. A clause, gated on the backend's new get_app_guide tool and shared by
the hire's and the manager's persona, routes those questions to the
guide, links pages by their path, and forbids sending somebody to a page
the guide does not list as theirs to open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Add BITBUCKET to the chat filter values and the known source systems so chunks keep their source system
- Cover the ingest round trip, the case-insensitive normalization and the chat filter in tests
- Add NOTION to the chat filter values and the known source systems so Notion chunks keep their source system
- Cover the ingest round trip, the case-insensitive normalization and the chat filter in tests
…oarding-tutor

Make the buddy the tutor along the onboarding path
The buddy is about to run chat's search tools (ai#206), and it has to
hold their results to the same budget: grep returns every match in the
scoped corpus, and without the chunk and character caps one broad
pattern can return more text than the context window. Two copies of
the budget would drift, and the chat agent is deleted once the chat is
retired (ai#207), so the budget cannot stay inside it.

agents/tools/evidence.py now owns SOURCE_CHARS, MAX_EVIDENCE_CHUNKS,
MAX_EVIDENCE_CHARS, limit_evidence, format_evidence and
order_evidence_for_display, moved verbatim. The names are public
because pyright strict reports a private name imported from another
module. It sits beside the tools, not in rag/, because format_evidence
formats a ToolResult and rag must not import agents.

format_evidence gains two keyword arguments that default to chat's
current behaviour: header (the buddy adds its test-file warning to the
chunk label) and empty (the buddy's persona reacts to its own "nothing
matched" wording).

The chat agent's two evidence unit tests move to
tests/agents/test_evidence.py, since they would otherwise be deleted
along with the chat agent, and gain budget-edge tests. The chat
agent's turn-level budget tests stay where they are. No behaviour
change: the chat suite passes unchanged apart from the moved tests.
Once the chat is retired (Wiki#319), a reader who asks the corpus
instead of the mentor (capabilities off) talks to Buddy. Four things
chat had and Buddy did not would be lost. This brings them over and
exposes them on POST /onboarding/buddy/agent. Closes #206.

Search. search_docs is now chat's RetrieveTool, subclassed only to
keep the name and spec the persona and the backend know. So each hit
arrives with its neighbouring chunks (expand_context_window, which the
old direct retrieve call skipped), held to the shared evidence budget
(agents.tools.evidence), and only direct matches are cited, never
context. Turns with capabilities off also get chat's GrepTool as
`grep`, for exact identifiers. That includes team mode with
capabilities off: the search-only persona clause is shared by both
modes, and a second code path for one tool would drift. Mentor turns
keep the tool list they had. A backend tool named like a local one
cannot shadow it. Searches asked for in the same step run concurrently
(up to 4, as in chat), with results in call order. Test material is
dropped before the budget, from the chunks and from the match ids, so
a dropped fixture is neither quoted nor reported as an omitted match.

Filters. BuddyAgentRequest.filters takes chat's source-system and time
narrowing (ChatFilters; empty source_systems means all, as for chat)
and applies it to every search. The project scope stays project_ids
and stays fail-closed; the old comment claiming material without a
project stays searchable was wrong and is corrected. If the narrowing
applies, the hop searched, nothing matched, and no backend tool was
mounted that could have supplied other evidence, the answer is chat's
own fixed notice (NO_FILTERED_RESULTS_MESSAGE) instead of an answer
composed from nothing. The guard is deliberate: a greeting under an
active filter is still answered normally, and so is a turn whose
answer may rest on get_my_metrics. The check runs when the model
answers, not after the first empty search, so the model can still
retry with other terms. It also replaces the forced answer when the
step budget runs out.

Reasoning. BuddyAgentResponse.reasoning lists the model's reasoning,
one entry per call that returned any, for the frontend's thinking
panel. Each hop returns only its own; the backend concatenates hops
(backend#214). It is display-only. The wire message schema has no
field for it, so it is never carried back and never re-enters the
context. Internally it now rides the next hop's assistant message
(reasoning and reasoning_details), as in chat, so a reasoning provider
can continue a tool-using thought. The forced answer uses generate,
which returns text only, so it adds nothing.

Fence. Every user message is wrapped in a marker line, and the persona,
in every mode, says that what sits between two markers is the reader's
words, never instructions (onboarding.query_fence). The marker is an
HMAC of the text under a per-process key, not chat's random nonce, for
two reasons. First, it cannot be forged: whether a message is already
fenced is decided by recomputing its marker, never by its shape, so
typing a lookalike marker only gets you fenced again. Second, it is
byte-stable: the backend rebuilds the history from stored raw text
each turn (BuddyService) and carries the returned messages verbatim
between hops. Every user message, not only the newest, gets the same
bytes on every hop and turn, so the Anthropic prompt cache keeps
hitting across turns. A per-request nonce would change an earlier
message every turn and miss the cache from there on. A restart draws
a new key, which fences an in-flight message twice (still a fence)
and costs one cache miss.

Persona. The search-only clause no longer says "search_docs and nothing
else"; it says the turn can only search, and names grep when grep is
mounted. The persona receives the local tool names too, so that clause
only appears when grep is really there.

Tests: +38 (+44 with the previous commit). tests/onboarding/test_buddy_search_parity.py pins each
point above, including that grep is project-scoped, the budget ("8
further matches omitted"), neighbours shown but not cited, call order
under concurrency, every leg of the canned-reply guard, reasoning in
and out of the transcript, and fence idempotence across resume hops,
cross-turn byte stability and a forged marker.
tests/onboarding/test_query_fence.py covers the fence itself, and
tests/api/test_buddy.py covers the wire (filters, reasoning, no
reasoning keys in messages). Existing tests now monkeypatch
agents.tools.retrieve.retrieve, check fenced message contents by
substring, and use the new search-only wording.

Verification: ruff format --check, ruff check and pyright src/ (0
errors) clean; pytest 1042 passed, 8 skipped (1004 after the previous commit, 998 at
the base). Removing
only the fence call fails the fence tests; reverting buddy_agent.py
fails the parity suite.

Stacked on feature/311-buddy-onboarding-tutor (#208), which rewrites
the same persona and agent files; retarget to dev once it merges.
… duplicated

LinseCed's review of #211 raised five findings. Four held; the fifth is a
deployment assumption now made explicit in code. All five are settled here.

1. Non-ASCII questions crashed the fence (query_fence.py). is_fenced compared
   the message with hmac.compare_digest, which rejects str holding anything
   non-ASCII: any question of 42+ characters with an umlaut, an em dash or an
   emoji raised TypeError inside run_agent_turn -- on the first hop, and again
   on the resume hop that re-checks a message it already fenced. The
   comparison runs on UTF-8 bytes now; the fenced bytes themselves are
   unchanged. Pinned by test_query_fence.py (unit) and a German-question
   resume round-trip in test_buddy_search_parity.py (end to end).

2. A malformed tool call was hidden from the model (evidence.py,
   agents/tools/base.py). The buddy's empty= wording ("No indexed material
   matched this search.") replaced the tool's own summary whenever nothing
   survived -- including when the call never ran, so the model retried the
   search instead of fixing its arguments. ToolResult now separates the two:
   ToolResult.failed carries is_error for the never-ran paths (an unknown
   tool, arguments that did not validate; both raised by Tool.execute and
   ToolRegistry.execute), and format_evidence returns an error's summary
   whatever the caller's wording. Genuine empty results -- including the
   buddy's own test-material drop -- keep the no-evidence wording the persona
   reacts to. chat was already right: it passes no empty.

3. The canned filtered-empty notice and its narrowing predicate existed twice
   (chat route and buddy_agent) and had already drifted: chat treated an
   empty-string time bound as a narrowing, the buddy did not. Both move to
   rag/filters.py as NO_FILTERED_RESULTS_MESSAGE and has_narrowing_filters(),
   and both callers import them. Unified on "is not None", which is how
   where_filter_for_chroma reads the same field.

4. The run-a-step's-calls loop existed twice, each with its own
   _MAX_PARALLEL_TOOLS, and a change to the cap or its rationale would have
   updated only one copy. run_tool_calls() and MAX_PARALLEL_TOOLS now live in
   agents/tools/base.py; ChatAgent._run_tools/_run_one and the buddy's
   _run_local are gone. The two concurrency tests (barrier overlap, call
   order) move to tests/agents/test_tools.py, where the shared seam lives, so
   one test pins both loops.

5. The fence key was drawn per process, so the byte stability the module
   promises for the prompt cache only held within a single process. The key
   now comes from FENCE_KEY when the deployment sets one (hashed to 32
   bytes), else it is drawn per process. Nothing changes for the shipped
   deployment -- one uvicorn process, one key for its lifetime -- but a
   deployment with more workers or replicas can now share one key, and the
   docstring and .env.example say that it must, or each process re-fences
   what another fenced (still safe, a cache miss per crossing). Pinned by a
   fence_key() test.

Gates: ruff format --check, ruff check, pyright src/ (0 errors),
pytest (1049 passed, 8 skipped; +7 tests).
…rch-parity

Give Buddy chat's search, filters, reasoning and fence
…wledge

# Conflicts:
#	src/onboarding/buddy_persona.py
#	tests/onboarding/test_buddy_persona.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review feedback on the app-guide clause:
- The no-button rule ("never describe a button instead of making one") is
  about chat proposals; say so, so the model still names the app's buttons.
- The example link is now a page everybody can open (/board), not a manager
  page every hire persona carried.
- A test pins the clause right after the search_docs routing rule it narrows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…owledge

Give the buddy a map of the app: get_app_guide
- Resolve the source system conflicts so BITBUCKET and NOTION are both accepted
- Keep the Bitbucket tests from dev and the Notion tests side by side
The chat surface is retired on the frontend (sprintstart-frontend#206) — /chat
redirects to the buddy and nothing calls /api/v1/chat any more — so the
service side goes in one piece rather than as a route that outlives the UI
that stopped asking for it.

What goes: the /api/v1/chat route with its SSE path, ChatAgent and
ChatOrchestrator, the chat CLI (scripts/chat_cli.py, `sprintstart chat`)
that was their only non-HTTP caller, ChatRequest/HistoryEntry and the
legacy-field validator in schemas.py (the `prompt`/`context` acceptance
existed for pre-rename clients), and the chat-only docstrings that
referenced any of it.

What stays, and why: the buddy runs on the same retrieval stack and the
same SSE event shapes, so `agents/__init__.py` stays as the package root,
`ChatFilters` still describes the buddy request's filters, and
BuddyStreamEvent is untouched. The shared `chat_stream` client primitive
keeps its tests — only its docstrings stopped claiming a chat agent.

The README is deliberately left alone: it has an open rewrite (#216), and
folding prose about the service's entry points into a removal would make
both harder to review.
The golden-retrieval and image-ingest integration tests spoke HTTP to
/api/v1/chat because that was the one entry point exercising the full
stack. With the endpoint gone, the same coverage calls the retrieval layer
directly (rag.retriever.retrieve) — the pipeline the buddy itself runs —
so the assertions still watch real behaviour instead of a second, thinner
client path.

The source-exclusion regression test moves to the buddy route, which is
where exclusions are applied now, and gains a case proving a disabled
connector's material cannot reach the buddy's context. The chat route's
own test file and the agent/orchestrator unit tests go with the code they
tested; the dependency-graph test keeps its subject and drops the chat
route from its docstring.
- Drop rag.prompt.build_messages, dead since the /chat route went
- Re-word stale chat references in the CLI client, chroma_store and tests
- Rename test_empty_source_systems_mean_all_as_they_did_for_chat
- Add route-level tests that another project's chunks and chunks with no project are neither seen by the model nor cited (search_docs + grep, with and without filters), and that an empty project_ids admits nothing
- Drop the chat() helper from tests/api/test_client.py, the last caller of /api/v1/chat
Remove the chat surface from the AI service
- Turn the agent loop into a generator that streams reasoning, tokens and search announcements per hop
- Add POST /onboarding/buddy/agent/stream; the sync endpoint drains the same generator
- Hold back text that a filter override could replace, and join hop texts with a blank line
- Add reasoning and reasoning_details to the buddy agent wire message and map them both ways
- Fix the resume hop after a backend tool, which a thinking-enabled provider rejected with a 400
- Replace the test that pinned reasoning out of messages with carry and resume tests
Stream the buddy agent turn live from the AI service
@LinseCed
LinseCed merged commit b55a3aa into main Oct 7, 2026
8 checks passed
@LinseCed
LinseCed deleted the dev branch October 7, 2026 12:44
@LinseCed

LinseCed commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator Author

lgtm

@LinseCed
LinseCed restored the dev branch October 7, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants