Batching of thoughts, improved permissions

This commit is contained in:
2026-05-09 16:32:12 -04:00
parent 722169adfd
commit 8c098c56fc
4 changed files with 98 additions and 5 deletions
+4
View File
@@ -16,3 +16,7 @@ working_directory: "/home/user/projects"
# Forward Goose's chain-of-thought reasoning to Signal (can be noisy)
forward_thoughts: false
# Auto-approve all tool permission requests without asking the user.
# When false (default), each tool call prompts the user for approval via Signal.
auto_approve_tools: false
+3
View File
@@ -19,6 +19,7 @@ class Config:
trusted_contacts: list[Contact] = field(default_factory=list)
working_directory: str = "."
forward_thoughts: bool = False
auto_approve_tools: bool = False
def is_trusted(self, number: str) -> bool:
"""Check whether a phone number belongs to a trusted contact."""
@@ -56,10 +57,12 @@ def load_config(path: Path | None = None) -> Config:
working_directory = raw.get("working_directory", ".")
forward_thoughts = raw.get("forward_thoughts", False)
auto_approve_tools = raw.get("auto_approve_tools", False)
return Config(
account=raw["account"],
trusted_contacts=contacts,
working_directory=working_directory,
forward_thoughts=forward_thoughts,
auto_approve_tools=auto_approve_tools,
)
+90 -5
View File
@@ -78,11 +78,20 @@ class GooseClient(Client):
The client-side methods exist only as no-op stubs required by the protocol.
"""
def __init__(self, forward_thoughts: bool = False) -> None:
# Flush accumulated thoughts after this many seconds of quiet
THOUGHT_FLUSH_DELAY = 3.0
# Flush immediately if accumulated thought text exceeds this length
THOUGHT_FLUSH_SIZE = 1000
def __init__(self, forward_thoughts: bool = False, auto_approve_tools: bool = False) -> None:
self._response_chunks: dict[str, list[str]] = {} # session_id -> chunks
self._forward_thoughts = forward_thoughts
self._auto_approve_tools = auto_approve_tools
self._ask_user: AskUserFunc | None = None
self._notify_user: NotifyUserFunc | None = None
# Thought batching state per session
self._thought_buf: dict[str, list[str]] = {}
self._thought_flush_tasks: dict[str, asyncio.Task] = {}
def set_ask_user(self, func: AskUserFunc) -> None:
"""Set the callback used to ask the Signal user a question."""
@@ -97,6 +106,46 @@ class GooseClient(Client):
chunks = self._response_chunks.pop(session_id, [])
return "".join(chunks)
def _accumulate_thought(self, session_id: str, text: str) -> None:
"""Add thought text to the buffer and schedule a flush."""
self._thought_buf.setdefault(session_id, []).append(text)
# Cancel any existing flush timer and restart it
if session_id in self._thought_flush_tasks:
self._thought_flush_tasks[session_id].cancel()
# Flush immediately if buffer is large enough
buf_text = "".join(self._thought_buf.get(session_id, []))
if len(buf_text) >= self.THOUGHT_FLUSH_SIZE:
self._thought_flush_tasks[session_id] = asyncio.create_task(
self._flush_thoughts(session_id)
)
else:
# Otherwise schedule a delayed flush
self._thought_flush_tasks[session_id] = asyncio.create_task(
self._delayed_flush(session_id)
)
async def _delayed_flush(self, session_id: str) -> None:
"""Wait for the debounce delay, then flush thoughts."""
await asyncio.sleep(self.THOUGHT_FLUSH_DELAY)
await self._flush_thoughts(session_id)
async def _flush_thoughts(self, session_id: str) -> None:
"""Send any accumulated thought text to the user."""
# Cancel the timer if one is running
task = self._thought_flush_tasks.pop(session_id, None)
if task is not None and not task.done():
task.cancel()
chunks = self._thought_buf.pop(session_id, [])
if not chunks or self._notify_user is None:
return
text = "".join(chunks).strip()
if text:
await self._notify_user(session_id, f"💭 {text}")
# -- Session update notifications --
async def session_update(self, session_id: str, update, **kwargs: Any) -> None:
@@ -111,9 +160,11 @@ class GooseClient(Client):
if isinstance(content, TextContentBlock):
log.debug("Agent thought [%s]: %s", session_id[:8], content.text[:80])
if self._forward_thoughts and self._notify_user:
await self._notify_user(session_id, f"💭 {content.text}")
self._accumulate_thought(session_id, content.text)
elif isinstance(update, ToolCallStart):
# Flush any pending thoughts before reporting the tool call
await self._flush_thoughts(session_id)
title = update.title or "(unknown)"
log.info("Tool call started [%s]: %s", session_id[:8], title)
if self._notify_user:
@@ -131,7 +182,31 @@ class GooseClient(Client):
tool_call: ToolCallUpdate,
**kwargs: Any,
) -> RequestPermissionResponse:
"""Ask the Signal user for permission via the bridge callback."""
"""Ask the Signal user for permission, or auto-approve if configured."""
if self._auto_approve_tools:
# Find the most permissive allow option
allow_option = None
for opt in options:
if opt.kind == PermissionOptionKind.ALWAYS_ALLOW:
allow_option = opt
break
elif opt.kind == PermissionOptionKind.ALLOW:
allow_option = opt
if allow_option is None:
allow_option = options[0]
log.info(
"Auto-approving tool [%s]: %s (option: %s)",
session_id[:8],
tool_call.title or "(unknown)",
allow_option.name,
)
return RequestPermissionResponse(
outcome=AllowedOutcome(
outcome="selected", option_id=allow_option.option_id
)
)
question, option_labels = _format_permission_message(tool_call, options)
if self._ask_user is not None:
@@ -231,9 +306,12 @@ def _parse_permission_reply(
class GooseConnection:
"""Manages the ACP connection to a Goose agent process."""
def __init__(self, cwd: str = ".", forward_thoughts: bool = False):
def __init__(self, cwd: str = ".", forward_thoughts: bool = False, auto_approve_tools: bool = False):
self.cwd = cwd
self.client = GooseClient(forward_thoughts=forward_thoughts)
self.client = GooseClient(
forward_thoughts=forward_thoughts,
auto_approve_tools=auto_approve_tools,
)
self._conn = None
self._process = None
self._ctx = None
@@ -313,6 +391,9 @@ class GooseConnection:
prompt_content = [TextContentBlock(type="text", text=text)]
response = await self._conn.prompt(prompt=prompt_content, session_id=session_id)
# Flush any remaining thoughts
await self.client._flush_thoughts(session_id)
log.info("Prompt complete [%s]: stop_reason=%s", session_id[:8], response.stop_reason)
text_response = self.client.take_response(session_id)
@@ -342,6 +423,10 @@ class GooseConnection:
prompt_content.append(ImageContentBlock(type="image", data=image_data, mime_type=mime_type))
response = await self._conn.prompt(prompt=prompt_content, session_id=session_id)
# Flush any remaining thoughts
await self.client._flush_thoughts(session_id)
log.info("Prompt (with image) complete [%s]: stop_reason=%s", session_id[:8], response.stop_reason)
return self.client.take_response(session_id)
+1
View File
@@ -54,6 +54,7 @@ async def run(args: argparse.Namespace) -> None:
goose = GooseConnection(
cwd=config.working_directory,
forward_thoughts=config.forward_thoughts,
auto_approve_tools=config.auto_approve_tools,
)
await goose.start()
log.info("Goose ACP connection established (cwd=%s)", config.working_directory)