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
1 change: 1 addition & 0 deletions changes/532.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**A small local model that writes a tool call out as text no longer shows you the raw call.** Models like qwen3:8b sometimes answered with the call itself, such as a note to remember, as text, so nothing happened. Sentient now makes that call for real, with the same checks and approvals as any other, or asks the model once to make it properly.
59 changes: 58 additions & 1 deletion sentient/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
reversible,
rule_levels,
second_look,
text_calls,
)
from sentient.agent import grants as grant_rules
from sentient.agent import incognito as incognito_outputs
Expand Down Expand Up @@ -208,6 +209,11 @@


# ---------------------------------------------------------------------- transcript helpers
def _call_key(name: str, arguments: Any) -> tuple[str, str]:
"""A call's tool and arguments, comparable across rounds."""
return name, json.dumps(arguments, sort_keys=True, ensure_ascii=False, default=str)


def _normalize_tool_calls(calls: Any) -> list[dict]:
out: list[dict] = []
seen: set[str] = set()
Expand Down Expand Up @@ -877,6 +883,9 @@ async def run_loop(
text_acc = ""
tools_disabled = False
empty_nudged = False
call_nudge: dict | None = None # the one nudge about a tool call written out as text (#532)
known = {t.name for t in self.registry.tools(include_hidden=True)}
made: set[tuple[str, str]] = set() # (tool, arguments) of every call this run made
round_no = 0
def interrupted() -> bool:
if interrupt is None or not interrupt():
Expand Down Expand Up @@ -906,6 +915,7 @@ def interrupted() -> bool:
ctx.user_words.append(text) # the user's own words count for "Only if I asked" (#140)
text_acc = ""
think_acc = ""
shown = 0 # characters of text_acc streamed so far; a possible written-out tool call is held back (#532)
tool_calls: list[ToolCall] = []
try:
async for chunk in self._model_stream(role, messages, tools, model, source == "chat" and not unprompted,
Expand All @@ -915,7 +925,11 @@ def interrupted() -> bool:
yield ThinkingDelta(text=chunk.thinking, **ev)
if chunk.text:
text_acc += chunk.text
yield TextDelta(text=chunk.text, **ev)
upto = (text_calls.held_from(text_acc, known, text_calls.line_start(text_acc, shown))
if tools else len(text_acc))
if upto > shown:
yield TextDelta(text=text_acc[shown:upto], **ev)
shown = upto
if chunk.done:
tool_calls = chunk.tool_calls
if chunk.model and chunk.model != (model or self.llm.model_for(role)):
Expand Down Expand Up @@ -966,6 +980,43 @@ def interrupted() -> bool:
return
result.thinking += think_acc
ctx.result_limit = result.tool_result_limit # a tool returning a part sizes it to fit (#336)
if call_nudge is not None and messages and messages[-1] is call_nudge:
messages.pop() # the model has answered the nudge: it is scaffolding, not part of the conversation
only = {tc.name for tc in tool_calls} if tool_calls else None
written = text_calls.find(text_acc, self._tool_schema, only) if tools and shown < len(text_acc) else None
streamed = text_acc[:shown]
if written is not None and tool_calls:
text_acc = written.cleaned # it made the real calls too: the written copy is never shown
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
elif written is not None and written.runnable and all(_call_key(n, a) in made for n, a in written.calls):
# real qwen3:8b wrote the same call out again after its result: it already ran, so this is the answer
log.info("%s: the model wrote out %s again after it ran; not running it twice", source,
", ".join(written.names))
text_acc = written.before
elif written is not None and written.runnable:
# a small model wrote its call out as text: it runs as the real call, through every usual check
log.info("%s: the model wrote %s out as text; running it as a call", source, ", ".join(written.names))
tool_calls = [ToolCall(id=f"text_call_{new_id()[:12]}", name=name, arguments=args)
for name, args in written.calls]
text_acc = written.before
elif written is not None and written.is_action:
text_acc = written.before
if call_nudge is None and round_no < rounds:
log.info("%s: the model wrote %s out as text and it can't run; asking once to call it",
source, written.names[0])
if shown < len(text_acc) and text_acc.startswith(streamed):
yield TextDelta(text=text_acc[shown:], **ev)
if text_acc.strip():
messages.append({"role": "assistant", "content": text_acc})
if persist:
await persist("assistant", text_acc, thinking=think_acc or None)
call_nudge = {"role": "user", "content": text_calls.NUDGE.format(name=written.names[0])}
messages.append(call_nudge)
continue
if not text_acc.strip() and not result.findings:
text_acc = text_calls.NOT_RUN_REPLY
if shown < len(text_acc) and text_acc.startswith(streamed): # the rest of what was held back, if any
yield TextDelta(text=text_acc[shown:], **ev)
shown = len(text_acc)

if not tool_calls:
pending = steer.take() if steer is not None else []
Expand Down Expand Up @@ -1014,6 +1065,7 @@ def interrupted() -> bool:
return
# a delete for good becomes its app's trash version unless the user asked for it to be permanent (#142);
# swapped before the call is saved, so the transcript, approvals and second look all see the trash call
made.update(_call_key(tc.name, tc.arguments) for tc in tool_calls)
swapped = self._prefer_trash(tool_calls, messages, ctx)
if swapped and tool_names is not None:
tool_names = [*tool_names, *(tc.name for tc in tool_calls if tc.id in swapped and tc.name not in tool_names)]
Expand Down Expand Up @@ -1330,6 +1382,11 @@ async def _plan_call(
plan.wants_ok, plan.ask_rule = True, False
return plan

def _tool_schema(self, name: str) -> dict | None:
"""The JSON schema of a registered tool's arguments, None for a name no tool has (#532)."""
tool = self.registry.get(name)
return tool.openai_schema()["function"]["parameters"] if tool is not None else None

def _prefer_trash(self, tool_calls: list[ToolCall], messages: list[dict], ctx: ToolContext) -> dict[str, str]:
"""Swap each delete-for-good call for its app's trash version, in place (#142, ``sentient.agent.reversible``).
Returns ``{call id: the tool the model called}`` for the calls swapped. Skipped when ``prefer_trash`` is off,
Expand Down
Loading
Loading