Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions src/built/agent/chat.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Project-level chat: a human and an LLM brainstorming and workshopping ticket
ideas together, with read-only repo-browsing tools plus create_ticket/update_ticket
to file or edit cards mid-conversation. The read-only-explore-plus-file-tickets
to file or edit cards mid-conversation, and unblock_card/archive_card/unarchive_card
for the human's board-maintenance requests. The read-only-explore-plus-file-tickets
sibling of agent/curation.py, but step-wise rather than run-to-completion: unlike
every other agent/* module, a "run" here is one bounded reply to whatever's new, not
a loop that only ends via a terminal tool. Persisted history
Expand Down Expand Up @@ -177,10 +178,11 @@ async def _dispatch(
tickets_created: int,
) -> tuple[str, bool, str | None]:
"""Repo-browsing tools go through the shared, DB-agnostic ToolDispatcher (same
as every other column); search_tickets/get_ticket/create_ticket/update_ticket
are dispatched here instead, inline, because they need `session` and
card_service — the first case in this codebase of one tool-call batch needing
both a worktree-scoped dispatcher AND direct DB access."""
as every other column); search_tickets/get_ticket/create_ticket/update_ticket/
unblock_card/archive_card/unarchive_card are dispatched here instead, inline,
because they need `session` and card_service — the first case in this codebase
of one tool-call batch needing both a worktree-scoped dispatcher AND direct DB
access."""
if name == "search_tickets":
query = str(arguments.get("query", "")).strip()
include_archived = bool(arguments.get("include_archived", False))
Expand Down Expand Up @@ -237,6 +239,50 @@ async def _dispatch(
return f"no such card: {card_id!r}", True, None
await session.commit()
return f"Updated card {card.id}: {card.title!r}.", False, card.id
if name == "unblock_card":
card_id = str(arguments.get("card_id", "")).strip()
note = str(arguments.get("note", "")).strip() or None
if not card_id:
return "card_id is required.", True, None
try:
card = await card_service.retry_card(session, card_id, note=note)
except card_service.NotFoundError:
return f"no such card: {card_id!r}", True, None
except ValueError as exc:
return str(exc), True, None
await session.commit()
message = (
f"Unblocked card {card.id}: {card.title!r} — back to ACTIVE in the "
f"{card.column.value} column with a fresh retry budget."
)
if note:
message += f" Note left for the next agent: {note}"
return message, False, card.id
if name == "archive_card":
card_id = str(arguments.get("card_id", "")).strip()
if not card_id:
return "card_id is required.", True, None
try:
card = await card_service.archive_card(session, card_id)
except card_service.NotFoundError:
return f"no such card: {card_id!r}", True, None
await session.commit()
return (
f"Archived card {card.id}: {card.title!r} — off the board, history intact. "
"It can be restored with unarchive_card if needed.",
False,
card.id,
)
if name == "unarchive_card":
card_id = str(arguments.get("card_id", "")).strip()
if not card_id:
return "card_id is required.", True, None
try:
card = await card_service.unarchive_card(session, card_id)
except card_service.NotFoundError:
return f"no such card: {card_id!r}", True, None
await session.commit()
return f"Restored card {card.id}: {card.title!r} — back on the board.", False, card.id
outcome = await dispatcher.dispatch(name, arguments)
return outcome.result.output, outcome.result.is_error, None

Expand Down
8 changes: 6 additions & 2 deletions src/built/agent/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,13 @@ def build_chat_prompt(
"workshopping ticket ideas with a human — including ones already on the board, not just new "
"ones. You have read-only tools to explore the actual repository; search_tickets/get_ticket "
"to find and inspect existing cards (use these instead of guessing an id whenever the human "
"references a card by name or description); and create_ticket/update_ticket to file or edit "
"references a card by name or description); create_ticket/update_ticket to file or edit "
"cards directly once an idea is concrete — use them proactively once the conversation reaches "
"a real, well-scoped decision, rather than waiting to be asked explicitly. A created card "
"a real, well-scoped decision, rather than waiting to be asked explicitly; and "
"unblock_card/archive_card/unarchive_card for board maintenance the human explicitly asks "
"for — unblocking a blocked/failed card (a human-initiated retry, not gated by the Reviver's "
"auto-retry budget), or archiving/unarchiving one to take it off the board or bring it back. "
"A created card "
"starts in the PM column and flows through PM -> Developer -> Tester -> Reviewer -> Deployer "
"autonomously from there; you are not implementing anything yourself, only shaping what gets "
"filed or changed. Keep replies conversational and concise — this is a live back-and-forth, "
Expand Down
71 changes: 68 additions & 3 deletions src/built/llm/tool_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -791,10 +791,72 @@
},
}

UNBLOCK_CARD = {
"type": "function",
"function": {
"name": "unblock_card",
"description": (
"Retry a blocked or failed card at the human's explicit request — re-activate it so "
"the pipeline picks it back up from its current column. Only call this when the human "
"has actually asked you to unblock a specific card. Unlike the Reviver's autonomous "
"retries this is a human override, so it isn't gated by the auto-retry budget; it resets "
"the card's safety-valve counters and leaves a fresh budget for the next run. The "
"optional note is surfaced to whichever agent runs the card next."
),
"parameters": {
"type": "object",
"properties": {
"card_id": {"type": "string", "description": "The blocked/failed card to unblock."},
"note": {
"type": "string",
"description": "Optional guidance for whichever column runs the card next.",
},
},
"required": ["card_id"],
},
},
}

ARCHIVE_CARD = {
"type": "function",
"function": {
"name": "archive_card",
"description": (
"Archive a card — hide it from the board and stop the pipeline from ever claiming it "
"again. History, events, and visits are all kept intact; the card stays reachable by id "
"and can be restored with unarchive_card. Use this when the human asks to take a card "
"off the board (e.g. it turned out not to be wanted)."
),
"parameters": {
"type": "object",
"properties": {"card_id": {"type": "string", "description": "The card to archive."}},
"required": ["card_id"],
},
},
}

UNARCHIVE_CARD = {
"type": "function",
"function": {
"name": "unarchive_card",
"description": (
"Restore a previously archived card back onto the board so the pipeline can work it "
"again. Use this when the human asks to bring a card back — e.g. after an accidental "
"archive."
),
"parameters": {
"type": "object",
"properties": {"card_id": {"type": "string", "description": "The archived card to restore."}},
"required": ["card_id"],
},
},
}

# Project chat (agent/chat.py) — read-only browsing plus search_tickets/get_ticket/
# create_ticket/update_ticket, all ordinary (non-terminal) tools: a chat turn ends when the
# model replies with no further tool calls, not by calling a designated "done" tool, so the
# conversation stays open after a card is looked up, filed, or edited.
# create_ticket/update_ticket/unblock_card/archive_card/unarchive_card, all ordinary
# (non-terminal) tools: a chat turn ends when the model replies with no further tool calls,
# not by calling a designated "done" tool, so the conversation stays open after a card is
# looked up, filed, edited, unblocked, or archived.
CHAT_TOOLS = [
READ_FILE,
LIST_FILES,
Expand All @@ -804,6 +866,9 @@
GET_TICKET,
CREATE_TICKET,
UPDATE_TICKET,
UNBLOCK_CARD,
ARCHIVE_CARD,
UNARCHIVE_CARD,
]

APPROVE = {
Expand Down
41 changes: 40 additions & 1 deletion src/built/sandbox/worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@
does not use refs/remotes/origin/*), so worktrees are created off the plain branch
name, not `origin/<branch>`."""

import logging
import re
from pathlib import Path

from built.config import settings
from built.db.models import Card, Project
from built.tools import git_tools
from built.tools.git_tools import GitCommandError, run_git

logger = logging.getLogger(__name__)

# git refuses to update a branch that is checked out in a linked worktree:
# "fatal: refusing to fetch into branch 'refs/heads/card/x' checked out at '<path>'".
_REFUSING_TO_FETCH_CHECKED_OUT = re.compile(r"refusing to fetch into branch '[^']+' checked out at ")


def bare_repo_path(project: Project) -> Path:
return settings.data_dir / "repos" / f"{project.id}.git"
Expand All @@ -35,13 +43,44 @@ async def ensure_managed_clone(project: Project) -> Path:
the current upstream tip)."""
bare_path = bare_repo_path(project)
if bare_path.exists():
await run_git("fetch", "origin", "+refs/heads/*:refs/heads/*", cwd=bare_path)
await _fetch_managed_clone(project, bare_path)
return bare_path
bare_path.parent.mkdir(parents=True, exist_ok=True)
await run_git("clone", "--bare", project.repo_remote_url, str(bare_path), cwd=bare_path.parent)
return bare_path


async def _fetch_managed_clone(project: Project, bare_path: Path) -> None:
"""Advance the bare clone's refs from the remote. Fetching the full
+refs/heads/*:refs/heads/* keeps every branch fresh, but git refuses to update
a branch that is checked out in a linked worktree — and every live card's
branch IS checked out in its own worktree. Once one card's branch has been
pushed to the remote (pr_to_operator), any later fetch for a *different* card
dies with "refusing to fetch into branch 'refs/heads/card/...' checked out at
..." and wedges that visit's setup. The default branch is never checked out
anywhere (card and tool worktrees both branch off it), so when the full fetch
trips over a checked-out branch, fall back to fetching just it — the one branch
worktree creation and tool resets actually need advanced."""
try:
await run_git("fetch", "origin", "+refs/heads/*:refs/heads/*", cwd=bare_path)
except GitCommandError as exc:
if not _REFUSING_TO_FETCH_CHECKED_OUT.search(exc.stderr):
raise
logger.info(
"full fetch for project %s refused on a checked-out worktree branch (%s); "
"falling back to fetching only %s",
project.id,
exc.stderr.strip().splitlines()[0],
project.default_branch,
)
await run_git(
"fetch",
"origin",
f"+refs/heads/{project.default_branch}:refs/heads/{project.default_branch}",
cwd=bare_path,
)


def tool_worktree_path(project: Project, *, tool: str) -> Path:
return settings.data_dir / "worktrees" / f"_tool_{tool}" / project.id

Expand Down
137 changes: 136 additions & 1 deletion tests/integration/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from built.agent.chat import run_chat_turn
from built.db.models import ChatMessage
from built.domain.enums import ChatRole
from built.domain.enums import ChatRole, LifecycleState
from built.llm.client import LLMResult, ToolCallRequest
from built.llm.tool_schemas import MAX_TICKETS_PER_CHAT_TURN
from built.main import app
Expand Down Expand Up @@ -271,6 +271,141 @@ async def test_chat_get_ticket_missing_card_yields_error_tool_result(db_session,
assert "no-such-card" in tool_result.content


async def test_chat_unblock_card_retries_a_blocked_card(db_session, toy_repo_remote):
project, wt_path = await _make_project(db_session, toy_repo_remote, _n="3e")
card = await card_service.create_card(db_session, project.id, title="Fix login bug", raw_request="r")
card.lifecycle_state = LifecycleState.BLOCKED
card.revision_count = 3
await chat_service.append_user_message(db_session, project.id, content="unblock the login bug card")
await db_session.commit()

llm = ScriptedLLMClient(
[
LLMResult(
content=None,
tool_calls=[
ToolCallRequest(
id="call_1",
name="unblock_card",
arguments={"card_id": card.id, "note": "rebase onto main first"},
)
],
endpoint_used="fake::model",
),
LLMResult(content="Done — it's back in the queue.", tool_calls=[], endpoint_used="fake::model"),
]
)

appended = await run_chat_turn(
db_session, project, llm_client=llm, dispatcher=_dispatcher(wt_path), max_tool_iterations=10
)

await db_session.refresh(card)
assert card.lifecycle_state == LifecycleState.ACTIVE
assert card.revision_count == 0
assert card.retry_note == "rebase onto main first"
tool_result = next(m for m in appended if m.role == ChatRole.TOOL)
assert not tool_result.is_error
assert tool_result.card_id == card.id
assert "Unblocked" in tool_result.content


async def test_chat_unblock_card_not_blocked_yields_error_tool_result(db_session, toy_repo_remote):
project, wt_path = await _make_project(db_session, toy_repo_remote, _n="3f")
card = await card_service.create_card(db_session, project.id, title="Add dark mode", raw_request="r")
await chat_service.append_user_message(db_session, project.id, content="unblock the dark mode card")
await db_session.commit()

llm = ScriptedLLMClient(
[
LLMResult(
content=None,
tool_calls=[
ToolCallRequest(id="call_1", name="unblock_card", arguments={"card_id": card.id})
],
endpoint_used="fake::model",
),
LLMResult(content="Can't — it isn't blocked.", tool_calls=[], endpoint_used="fake::model"),
]
)

appended = await run_chat_turn(
db_session, project, llm_client=llm, dispatcher=_dispatcher(wt_path), max_tool_iterations=10
)

tool_result = next(m for m in appended if m.role == ChatRole.TOOL)
assert tool_result.is_error
assert "cannot retry" in tool_result.content
assert card.lifecycle_state == LifecycleState.ACTIVE


async def test_chat_archive_card_hides_it_from_the_board(db_session, toy_repo_remote):
project, wt_path = await _make_project(db_session, toy_repo_remote, _n="3g")
card = await card_service.create_card(db_session, project.id, title="Dead idea", raw_request="r")
await chat_service.append_user_message(db_session, project.id, content="drop that card from the board")
await db_session.commit()

llm = ScriptedLLMClient(
[
LLMResult(
content=None,
tool_calls=[
ToolCallRequest(
id="call_1", name="archive_card", arguments={"card_id": card.id}
)
],
endpoint_used="fake::model",
),
LLMResult(content="Archived it.", tool_calls=[], endpoint_used="fake::model"),
]
)

appended = await run_chat_turn(
db_session, project, llm_client=llm, dispatcher=_dispatcher(wt_path), max_tool_iterations=10
)

await db_session.refresh(card)
assert card.archived_at is not None
tool_result = next(m for m in appended if m.role == ChatRole.TOOL)
assert not tool_result.is_error
assert tool_result.card_id == card.id
assert await card_service.list_cards(db_session, project.id) == []


async def test_chat_unarchive_card_restores_it_to_the_board(db_session, toy_repo_remote):
project, wt_path = await _make_project(db_session, toy_repo_remote, _n="3h")
card = await card_service.create_card(db_session, project.id, title="Keep after all", raw_request="r")
await card_service.archive_card(db_session, card.id)
await db_session.commit()
await chat_service.append_user_message(db_session, project.id, content="actually, bring that card back")
await db_session.commit()

llm = ScriptedLLMClient(
[
LLMResult(
content=None,
tool_calls=[
ToolCallRequest(
id="call_1", name="unarchive_card", arguments={"card_id": card.id}
)
],
endpoint_used="fake::model",
),
LLMResult(content="It's back.", tool_calls=[], endpoint_used="fake::model"),
]
)

appended = await run_chat_turn(
db_session, project, llm_client=llm, dispatcher=_dispatcher(wt_path), max_tool_iterations=10
)

await db_session.refresh(card)
assert card.archived_at is None
tool_result = next(m for m in appended if m.role == ChatRole.TOOL)
assert not tool_result.is_error
assert [c.id for c in await card_service.list_cards(db_session, project.id)] == [card.id]


async def test_chat_no_op_guard_when_history_already_ends_on_assistant(db_session, toy_repo_remote):
project, wt_path = await _make_project(db_session, toy_repo_remote, _n="4")
await chat_service.append_user_message(db_session, project.id, content="hello")
Expand Down
Loading
Loading