diff --git a/.agents/skills/refactor/SKILL.md b/.agents/skills/refactor/SKILL.md new file mode 100644 index 00000000..ce6740c5 --- /dev/null +++ b/.agents/skills/refactor/SKILL.md @@ -0,0 +1,59 @@ +--- +name: refactor +description: > + Use when cleaning or refactoring this Gorkie Slack codebase. Covers the local + cleanup style: simplifying names, collapsing thin helpers, splitting real + ownership boundaries, trimming tool schemas, preserving Slack/AI behavior, and + running the repo validation suite. +--- + +# Gorkie Refactor + +Clean by reducing jumps, not by adding architecture. + +## Pass Shape + +1. Read the nearby source first. Do not rename or split from vibes. +2. Find names that describe implementation history instead of current purpose. + Prefer direct names like `reply`, `turns`, `buildPrompt`, `annotateMentions`. +3. Collapse helpers that only wrap one line or have no ownership. Inline them at + the call site unless they hide a real boundary. +4. Split files only when a module owns a coherent concept: turn state, compaction, + sandbox setup, Slack mention annotation, tool factories, task rendering. +5. Make factory naming consistent. If most factories use `*Tool`, fix outliers + without changing model-facing tool keys. +6. Keep schemas terse. Add `.describe()` only for fields whose contract is not + obvious from the name. +7. Keep type ownership explicit. Private one-file shapes stay inline; shared or + exported shapes live in the nearest clear owner `types/` folder. App-wide bot + shapes can live under `apps/bot/src/types/`. Tool-owned shared shapes use a + `types/tools/.ts` path. +8. Remove stale docs and prompts after renames. Search for old names before + handoff. + +## Gorkie-Specific Smells + +- `index.ts` files that own lifecycle, state, IO, and helpers at once. +- `create*`, `build*`, `with*`, `resolve*` names that hide a tiny operation. +- Three optional fields where a discriminated shape is clearer. +- Long async closures inside tool factories or object literals. +- User-facing task names that describe internal status instead of the action. +- Exported interfaces living in implementation files when an owned `types/` + folder is the clearer home. +- Tool-owned shared types outside a `types/tools/` folder. +- Stale prompt/docs references after tool or agent module renames. + +## Validation + +After code changes, run: + +```bash +bun run fix +bun run typecheck +bun run check +bun run check:spelling +bun run check:knip +``` + +For file moves, deleted exports, or public entry points, also run stale-name +searches with `rg` and, when practical, a non-starting import smoke. diff --git a/.claude/skills/refactor b/.claude/skills/refactor new file mode 120000 index 00000000..bf174cc1 --- /dev/null +++ b/.claude/skills/refactor @@ -0,0 +1 @@ +../../.agents/skills/gorkie-cleanup \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d07e133e..f25c1c55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,8 @@ Each conversation gets its own persistent E2B sandbox. The sandbox is remote Lin - Chat SDK: `https://github.com/vercel/chat` - Pi internals when needed: `https://github.com/earendil-works/pi` - Docs and architecture: start in `docs/index.md`. -- Cleanup tracker: `REWRITE_TODO.md`. -- Long-form build plan and coding examples: `REWRITE_PLAN.md`. +- Cleanup tracker: `TODO.md`. +- Long-form build plan and coding examples: `plans/rewrite.md`. ## Where Things Belong @@ -42,7 +42,7 @@ Each conversation gets its own persistent E2B sandbox. The sandbox is remote Lin ## Coding Rules -Full detail and examples live in `REWRITE_PLAN.md`. +Full detail and examples live in `plans/rewrite.md`. - Inline over extract: no one-shot helpers. - Avoid constants unless absolutely needed: inline one-use literals and values. diff --git a/REWRITE_TODO.md b/REWRITE_TODO.md deleted file mode 100644 index 8ce7f861..00000000 --- a/REWRITE_TODO.md +++ /dev/null @@ -1,181 +0,0 @@ -# Gorkie v2 TODO - -Working notes for the rewrite. `REWRITE_PLAN.md` is the architectural plan; this file tracks concrete remaining work, refactors, verification, and upstream bets. Keep this priority-sorted and update checkboxes as work lands. - -## P0 - Cleanup Before Slack History - -- [ ] Decide whether `apps/bot/src/lib/ai/stream/index.ts` should keep its three stream-state collections or move to a small state object. Do not refactor unless it clearly reduces clutter. -- [ ] Live verify markdown-heavy long Slack responses split into follow-up messages without `msg_too_long`, broken tables, broken code fences, stranded list items, or dangling intro lines. -- [ ] Add-assistant sidebar features, like context on the assistant sidebar for channel id, etc. `b3da0360c12bb8d1c28fd9849c18fbb747845698`. - -## P1 - Bounded Slack Context And History - -- [ ] Add a bounded Slack context prelude before each Pi prompt so Gorkie starts with the local Slack situation instead of needing to discover basic context from inside the sandbox. -- [ ] Add per-user Slack search using the Acting User's user token, not a shared bot/admin token, so Slack filters public channels, private channels, DMs, group DMs, users, and files to exactly what that user can access. -- [ ] Store Slack user-token authorization separately from bot credentials. Token lookup must be keyed by Acting User, and tool calls must fail closed if the requesting user has not authorized the needed user scopes. -- [ ] Add user-token search tools for Slack content/files/users with explicit result provenance: every prompt/tool result should say the results came from the Acting User's Slack-visible scope, not from global bot memory. -- [ ] Keep user-token search privacy scoped. Do not persist raw private-channel/DM search results into shared agent memory, global caches, or another user's thread context; only summarize into the current turn when the Slack surface is safe for the participants. -- [ ] Add guardrails for shared-channel use: if a user searches their private DMs/private channels from a public or multi-user thread, require a private draft/confirmation step before posting any sensitive result back into the shared thread. -- [ ] Thread mention behavior: when mentioned in a Slack thread, fetch the latest thread messages before prompting Pi. Target the Claude-like practical bound of the last 50 thread messages, sorted chronologically. -- [ ] Channel mention behavior: when mentioned in a channel root message, fetch recent top-level channel messages before prompting Pi. Target the Claude-like practical bound of the last 20 channel messages, sorted chronologically, excluding irrelevant bot/self noise. -- [ ] Use Chat SDK APIs first: adapter `fetchMessages` for thread replies and adapter `fetchChannelMessages` for channel context. Use Chat SDK state helpers only when pagination semantics are explicitly desired. -- [ ] Convert fetched Slack messages with `toAiMessages` from `chat/ai` when feeding model-message shaped context is useful. Prefer `includeNames: true` for multi-user thread context so Pi can distinguish speakers. -- [ ] Keep the model-facing history surface small: `listThreads` for public channel thread discovery and `readConversationHistory` for public channel/thread reads. Add another tool only if the model cannot complete a real workflow cleanly. -- [ ] Tighten Slack reader-tool privacy gates during broad history work. Gorkie can read DMs it has token access to, which is useful for current DM conversations but dangerous if tools let one user fetch or search another user's private DM context. Scope DM reads to the current DM/thread, block or require explicit approval for cross-DM reads, and make model-facing tool descriptions say private conversations are not general workspace memory. -- [ ] Make prompt text explicit: tell Pi which context was preloaded, the bounds used, and that anything outside those bounds requires calling Slack/Chat tools rather than pretending it saw the whole workspace. -- [ ] Bound by message count first, then add a token/character budget if real Slack threads produce oversized prompts. Trim oldest messages first, preserving the triggering message and direct parent/root. -- [ ] Include attachments only through Chat SDK supported paths. `toAiMessages` can include images and text-like files when `fetchData()` exists; log skipped unsupported attachments without failing the turn. -- [ ] Respect permissions and private-channel access. If Slack history fetch fails, continue with a short context note saying history was unavailable and let Pi use tools if needed. -- [ ] Add tests or harnessed smoke coverage for: thread mention with 50-message cap, channel mention with 20-message cap, DM follow-up, failed history fetch, and attachment-skipping behavior. -- [ ] Live verify in Slack that Gorkie can answer a thread-context question from the preload, and can still call `readConversationHistory` for older or broader context. - -## P2 - Reliability Verification - -- [ ] Verify sandbox deletion recovery: delete or destroy a stored sandbox, send a follow-up in the same Slack thread, confirm v2 creates a fresh sandbox, re-seeds the mirrored Pi session file, and preserves conversation memory. -- [ ] Verify attachment seeding after a fresh sandbox resume. -- [ ] Verify Slack App Home: open, edit instructions, load preset, save preset, clear instructions. -- [ ] Verify Slack root mention vs reply-only mention vs subscribed thread behavior. -- [ ] Verify Langfuse receives AI SDK spans using `@ai-sdk/otel` + `LangfuseSpanProcessor`. [it doesn't!!] - -## P3 - Tool Scope Decisions -- TODO: Markdown rendering is broken in sendMessage, sendMessageToChannel - -and buggy in normal responses -- [ ] Investigate a Harness/Pi-native subagent flow for `summarizeThread`. AI SDK has generic tool-called subagent docs, but current Gorkie wiring does not expose a clean way to run a delegated Harness/Pi agent with the same per-turn attempt, sandbox/session shape, and cancellation behavior. Keep the direct AI SDK summary path until that contract is explicit. -- [ ] Evaluate Slack carousel blocks for multi-item tool outputs, especially generated images and file lists. Decide whether carousel output should replace the current repeated file posts, and revisit native Chat SDK streaming once carousel/task rendering is stable. -- [ ] Add a draft-first DM tool flow: Gorkie can prepare a DM or group-DM message for the Acting User, show recipient/body/context, and require explicit approval before any send. -- [ ] Support user-token DM actions only behind per-user authorization: start DMs/group DMs and send messages on behalf of the Acting User when scopes allow it, but never silently fall back to a shared user token. -- [ ] Add edit/cancel/send states for drafted DMs and group DMs. The user should be able to revise generated copy before sending, cancel it without side effects, or send after a clear confirmation. -- [ ] Keep bot-DM and user-DM semantics separate in tool names, prompts, task rows, and logs. `sendDirectMessage` as the bot is not the same as drafting or sending as a user. -- [ ] Add task renderers for DM draft lifecycle: drafting, awaiting approval, edited, sent, canceled, and failed. Avoid dumping raw message bodies into logs/task summaries when the destination is private. -- [ ] Improve scheduled reminders, to say here's your reminder for xyz you asked in this thread, xyz -- [ ] Rewrite and cleanup line-reply, since it has a BUNCH of code, which can just be achieved from asking the ai to do \n\n for every response, like tables, etc. and not to exceed 3k -- [ ] Refactor error message rendering - -## P4 - BYOK Proper - -- [x] Add `BYOK_ENCRYPTION_KEY` to bot env validation and deployment docs; require enough entropy for AES-256-GCM key derivation. -- [ ] Add tests for the bot-owned versioned secret encryption helper: round-trip, tamper failure, wrong-key failure, malformed ciphertext, and no plaintext in thrown errors. -- [x] Add `user_model_credentials` Drizzle schema and queries keyed by `(user_id, provider)`; store encrypted key, base URL metadata, provider slug, selected model id, key preview, validation status, validation message, last-used timestamp, and audit timestamps. -- [x] Keep raw BYOK secrets out of `packages/db` query logs and return types unless a bot-owned decrypting service explicitly asks for the encrypted payload. -- [x] Define a single exported provider discriminant union and provider adapter map in `packages/ai` for Pi attempts: OpenRouter-compatible, opencode-go, and any direct provider added later. -- [x] Replace static `chatAttempts` call sites with per-Acting-User attempt resolution: BYOK attempts first, service attempts only when no BYOK exists or explicit service fallback is enabled. -- [x] Update `executeTurn`, `executeCompact`, `nextAttempt`, and attempt logging so provider/model selection is per turn and logs never include raw env or key material. -- [x] Refactor `summarizeThread` to receive a per-turn language model when the BYOK provider can produce one. -- [ ] Make `generateImage` policy explicit in code/UI: service image provider only unless an image-capable BYOK provider is configured. -- [x] Add App Home Model Keys UI: add/rotate/delete credential, provider input, model id input, optional base URL, status display, and masked key preview only. -- [x] Ensure Slack modal `private_metadata`, view state re-renders, prompt hints, Pi session files, E2B env, task renderers, and logs never contain raw API keys. -- [ ] Validate credentials on save or first use, store safe validation status, and surface invalid-key/auth/quota errors to only the Acting User where Slack allows. -- [x] Decide service fallback UX: default off for BYOK failures, with an explicit opt-in if users may spend the shared service key after their key fails. -- [ ] Add tests for provider-to-`customEnv` mapping, BYOK-first fallback order, service fallback disabled, invalid-key handling, App Home modal parsing, and ownership checks. -- [ ] Live smoke: save a key, run a Slack turn, run compaction, rotate the key, delete the key, trigger an invalid-key turn, and verify two users in one thread use separate credentials without leaking provider/key details. - -## Upstream AI SDK / Harness Expectations - -- [ ] Native MCP support and skill support. -- [ ] Native steering support exposing queued user messages / `submitUserMessage` cleanly. -- [ ] Refactored Pi provider selection that is not ENV-prefix and model-id-order dependent. -- [ ] `ai-retry` support at the Harness/Pi boundary so custom retry logic can be deleted. -- [ ] Native Langfuse / OTel support deep enough for Harness/Pi model/tool/session internals. -- [ ] Official AI SDK E2B provider support with the resume/session-file hooks Gorkie needs. -- [ ] Add Pi-level retry parity from the old implementation so transient provider failures can retry within Pi before Gorkie's outer attempt fallback runs. - -## Borked Stuff -- [ ] Normalize user IDs [ping word] to user id & display name. e.g @Uxxxxxxx [@gorkie DEV] -- [ ] Add a prefix by who the message was sent by, e.g twa: make me a website, izie: idk -- [ ] Even though gorkie fetches the profile, it couldn't get a user's pronouns?? [chat sdk bug] -- [ ] When there are more than 50 task items gorkie fails -- [ ] Gorkie doesn't switch model properly -- [ ] "gorkies responses are shaped by instructions", when using custom instructions -- [ ] Commands needs proper timeout, and they should timeout before the sandbox expires: -E.G: Running commandpython3 -m http.server 8080 & -SERVER_PID=$! -sleep 1 - -echo "Server PID: $SERVER_PID" -ps aux | grep "http.server" | grep -v grep - -When this bug occurs it's impossible to stop gorkie -- [ ] -[2026-06-22 04:19:41.735 +0000] ERROR: [agent] turn failed - attempt: { - "model": "qwen3.7-max", - "provider": "opencode-go" - } - threadId: "slack:C0B7QEK0MQB:1782100463.342159" - err: { - "type": "Error", - "message": "524 {\"type\":\"https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-524/\",\"title\":\"Error 524: A timeout occurred\",\"status\":524,\"detail\":\"The origin web server did not return a complete response within the 120-second Proxy Read Timeout window. The connection was established, but the origin took too long to respond.\",\"instance\":\"a0f8717e1ca6634a\",\"error_code\":524,\"error_name\":\"origin_response_timeout\",\"error_category\":\"origin\",\"ray_id\":\"a0f8717e1ca6634a\",\"timestamp\":\"2026-06-22T04:19:31Z\",\"zone\":\"dashscope-us.aliyuncs.com\",\"cloudflare_error\":true,\"retryable\":true,\"retry_after\":120,\"owner_action_required\":true,\"what_you_should_do\":\"**Wait and retry.** Back off for at least 120 seconds. If the error persists, the website operator should check for long-running processes or an overloaded origin.\",\"footer\":\"This error was generated by Cloudflare on behalf of the website owner.\"}", - "stack": - Error: 524 {"type":"https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-524/","title":"Error 524: A timeout occurred","status":524,"detail":"The origin web server did not return a complete response within the 120-second Proxy Read Timeout window. The connection was established, but the origin took too long to respond.","instance":"a0f8717e1ca6634a","error_code":524,"error_name":"origin_response_timeout","error_category":"origin","ray_id":"a0f8717e1ca6634a","timestamp":"2026-06-22T04:19:31Z","zone":"dashscope-us.aliyuncs.com","cloudflare_error":true,"retryable":true,"retry_after":120,"owner_action_required":true,"what_you_should_do":"**Wait and retry.** Back off for at least 120 seconds. If the error persists, the website operator should check for long-running processes or an overloaded origin.","footer":"This error was generated by Cloudflare on behalf of the website owner."} - at (/workspaces/worktrees/gorkie-slack/thermo-cleanup/gorkie-slack/node_modules/.bun/@ai-sdk+harness-pi@1.0.0-beta.11+04cb25890130b012/node_modules/@ai-sdk/harness-pi/dist/index.js:1660:53) - at processTicksAndRejections (native:7:39) - } - -524: Timeout -- [ ] can you add a thing to ask gorkie to be able to leave a thread even if its pinged in it -- [ ] Enforcing if people are in #gorkie channel is borken in dev - - - -● You've zeroed in on the real limitation. Let me be precise: - - "Can we force a timeout?" — yes, but it doesn't fix this hang - - You can force the model to set a timeout (required timeout param via a bash override). But that won't rescue the http.server & no-redirect case, and here's why: - - That hang isn't a running command waiting — the script already exited (exit 0). What's stuck is E2B's commands.run waiting for a stdout pipe that an orphaned background process still holds open. A timeout (or a stop) fires an - abort — but abort/timeout only help if they kill the process that holds the fd. E2B's abort evidently doesn't reap that orphaned child (that's exactly why your Stop "does nothing" here). A timeout would behave the same way — it - bounds a blocking command, but it can't close a pipe held by a process it isn't killing. - - So: forcing a timeout fixes the "blocking command runs too long" case, not the "orphaned bg process leaked the fd" case. Different bug. - - How you actually handle the orphan-fd case - - Only two things truly work: - - (a) Prevent it — prompt rule (cheap, no code surgery): - Tell the model: background processes must redirect output and detach: - python -m http.server 8080 > /tmp/s.log 2>&1 & # redirect frees the fd - With the redirect, the orphan never holds commands.run's pipe → it returns in ~1 ms → no hang ever. This is the reliable fix for the common case, and it's why the prompt rule matters more than the timeout. - - (b) Reap it — custom bash override (bulletproof): - This is what opencode does and why theirs can always be stopped: they spawn detached (own process group) and on abort/timeout call kill({ forceKillAfter: "3s" }) on the whole group — SIGTERM then SIGKILL. That reaps the orphan - even when the model forgets to redirect. The cost is reimplementing bash as a host tool. - - So the honest summary - - - A timeout (forced or default) bounds blocking commands — good, do it (8-min ceiling + model-set timeouts). - - It does not fix the orphaned-bg-fd hang. For that you either prevent it (prompt: redirect+detach) or reap it (custom bash with process-group kill). - - Stop "not working" is specifically the orphan case — there's no live foreground process for the abort to kill, and E2B isn't reaping the child. - - Pragmatic path for Monday: 8-min ceiling + the redirect/background prompt rule (covers ~all real cases). The detached-process-group bash override is the eventual "can never get stuck" upgrade. Want me to do the pragmatic two - now? - -✻ Sautéed for 1m 1s - -❯ in pi - -● Short answer: you can't fix the orphan in Pi — Pi doesn't run the process. - - Look at the chain again: Pi's remoteOps.exec only does the signal wiring (the AbortController, the setTimeout, chaining your stop signal). Then it hands off: - runShell(cmd) → options.sandbox.run({ command, abortSignal }) // ← YOUR E2BSandboxSession - So Pi never spawns, never kills, never touches a process group. The actual commands.run / kill happens in your packages/sandbox/src/session.ts against E2B. Pi just passes an abort signal down and waits. - - That means: - - Process-group reaping (the opencode forceKillAfter group-kill) has to live in your sandbox layer, not in Pi. Either in E2BSandboxSession.run (wrap the command in its own group and kill the group on abort/timeout) or in a - custom bash tool you own. Pi can't do it for you. - - Pi also has no timeout/default knob. PiHarnessSettings is only { auth, model, thinkingLevel }. harness-pi's exec applies a timer only when the model passes timeout — there's no Pi-level default to set. So "force a default - timeout in Pi" isn't a thing either. - - So everything you'd want — a default ceiling, forced timeouts, orphan reaping — lands in your code, not Pi: - - Ceiling/default: config.ts executionTimeoutMs (the timeoutMs you pass to E2B). ✅ easy. - - Forced timeout param: override bash with a required-timeout host tool. (medium) - - Orphan reaping / always-killable: custom bash (or session.run) that spawns detached + group-kills on abort. (medium, the real "never stuck" fix) - - Pi gives you the signal plumbing; the process control is yours. So for Monday the realistic lever is still the 8-min ceiling in config.ts + the redirect/background prompt rule, since those are the only ones that don't require - taking over Pi's bash. Want those two? - -✻ Brewed for 48s -- [ ] For some reason gorkie can read threads in private channels fix it -- [ ] Test out leaving thread: verify the `leaveThread` tool actually works end-to-end — ask gorkie to leave, confirm it stops auto-responding to that thread, and confirm it can still be @-mentioned back in. diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..a751e034 --- /dev/null +++ b/TODO.md @@ -0,0 +1,290 @@ +# Gorkie TODO + +Concrete remaining work for the current rewrite, plus open TODOs imported from +the v1 reference codebase. The active v2 tracker comes first. The rewrite plan +lives in `plans/rewrite.md`. + +## Slack Context And History + +### Summaries + +- [ ] Revisit `summarizeThread` subagent design after there is an ephemeral + Harness/Pi session path that does not create persistent sandbox rows. + +### Context Prelude + +- [ ] Add a bounded Slack context prelude before each Pi prompt so Gorkie starts + with the local Slack situation instead of discovering basic context through + tools. +- [ ] Thread mention preload: when mentioned in a Slack thread, fetch the latest + thread messages before prompting Pi. Target the last 50 thread messages, + sorted chronologically. +- [ ] Channel root mention preload: when mentioned in a channel root message, + fetch recent top-level channel messages before prompting Pi. Target the last + 20 channel messages, sorted chronologically, excluding irrelevant bot/self + noise. +- [ ] Make prompt text explicit about which context was preloaded, the bounds + used, and that anything outside those bounds requires Slack/Chat tools. +- [ ] Bound by message count first, then add a token/character budget if real + Slack threads produce oversized prompts. Trim oldest messages first while + preserving the triggering message and direct parent/root. +- [ ] Include attachments only through Chat SDK supported paths. `toAiMessages` + can include images and text-like files when `fetchData()` exists; log skipped + unsupported attachments without failing the turn. + +### Chat SDK Reads + +- [ ] Use Chat SDK APIs first: adapter `fetchMessages` for thread replies and + adapter `fetchChannelMessages` for channel context. Use Chat SDK state helpers + only when pagination semantics are explicitly needed. +- [ ] Convert fetched Slack messages with `toAiMessages` from `chat/ai` when + model-message shaped context is useful. Prefer `includeNames: true` for + multi-user thread context so Pi can distinguish speakers. +- [ ] Keep the model-facing history surface small: `listThreads` for public + channel thread discovery and `readConversationHistory` for public + channel/thread reads. Add another reader only if a real workflow cannot be + completed cleanly. +- [ ] Fix private-channel/thread access behavior. Gorkie should not let one user + use bot/tool access as global private-channel memory. + +### User-Scoped Search + +- [ ] Add per-user Slack search using the Acting User's user token, not a shared + bot/admin token, so Slack filters public channels, private channels, DMs, + group DMs, users, and files to exactly what that user can access. +- [ ] Store Slack user-token authorization separately from bot credentials. + Token lookup must be keyed by Acting User, and tool calls must fail closed + when the requesting user has not authorized the needed user scopes. +- [ ] Add user-token search tools for Slack content, files, and users with + explicit result provenance. Tool results should say they came from the Acting + User's Slack-visible scope, not from global bot memory. +- [ ] Keep user-token search privacy scoped. Do not persist raw private-channel + or DM search results into shared agent memory, global caches, or another + user's thread context. +- [ ] Add shared-channel guardrails. If a user searches private DMs or private + channels from a public or multi-user thread, require a private + draft/confirmation step before posting sensitive results back to the shared + thread. + +## Slack Actions And UX + +### Thread Behavior + +- [ ] Verify Slack root mention, reply-only mention, and subscribed-thread + behavior. +- [ ] Fix dev channel enforcement for the `#gorkie` channel. + +### Posting And Drafting + +- [ ] Support user-token DM actions only behind per-user authorization. Starting + DMs/group DMs and sending messages on behalf of the Acting User must never + silently fall back to a shared user token. +- [ ] Add DM draft lifecycle task renderers: drafting, awaiting approval, + edited, sent, canceled, and failed. Do not dump private message bodies into + logs or task summaries. +- [ ] Evaluate Slack carousel blocks for multi-item tool outputs, especially + generated images and file lists. Decide whether carousel output should replace + repeated file posts after task rendering is stable. +- [ ] Add assistant-sidebar context features, such as visible channel id/context + metadata. Reference commit: `b3da0360c12bb8d1c28fd9849c18fbb747845698`. + +## Streaming And Task Rendering + +- [ ] Decide whether `apps/bot/src/lib/ai/stream/index.ts` should keep its three + stream-state collections or move to a small state object. Refactor only if it + clearly reduces clutter. +- [ ] Live verify markdown-heavy long Slack responses split into follow-up + messages without `msg_too_long`, broken tables, broken code fences, stranded + list items, or dangling intro lines. +- [ ] Verify task activity stays under Slack limits when reasoning blocks and + tool rows are both present. Slack becomes unstable around large task counts. +- [ ] Refactor error message rendering so user-facing failures are lower-case, + stage-aware, and do not confuse provider failures with Slack rendering bugs. + +## Sandbox And Commands + +### Sandbox Recovery + +- [ ] Verify sandbox deletion recovery: delete or destroy a stored sandbox, send + a follow-up in the same Slack thread, confirm v2 creates a fresh sandbox, + re-seeds the mirrored Pi session file, and preserves conversation memory. + +### Command Execution Hangs + +- [ ] Add a prompt rule for background commands: redirect output and detach, for + example `python -m http.server 8080 > /tmp/server.log 2>&1 &`. +- [ ] Investigate a host-owned bash override or sandbox `run` wrapper that + starts commands in a process group and kills the group on abort/timeout. This + is the real fix for orphaned background processes that keep stdout/stderr + pipes open after the shell exits. + +### Provider Timeout Evidence + +- [ ] Handle retryable provider 524s cleanly. Observed failure: + `qwen3.7-max` via `opencode-go` returned a Cloudflare 524 + `origin_response_timeout` from `dashscope-us.aliyuncs.com` with + `retryable: true` and `retry_after: 120`. + +## Models, BYOK, And Providers + +### BYOK Implementation + +- [ ] Add `BYOK_ENCRYPTION_KEY` to bot env validation and deployment docs. + Require enough entropy for AES-256-GCM key derivation. +- [ ] Add a bot-owned versioned secret encryption helper. +- [ ] Add tests for the encryption helper: round-trip, tamper failure, + wrong-key failure, malformed ciphertext, and no plaintext in thrown errors. +- [ ] Add `user_model_credentials` Drizzle schema and queries keyed by + `(user_id, provider)`. Store encrypted key, base URL metadata, provider slug, + selected model id, key preview, validation status, validation message, + last-used timestamp, and audit timestamps. +- [ ] Keep raw BYOK secrets out of `packages/db` query logs and return types + unless a bot-owned decrypting service explicitly asks for the encrypted + payload. +- [ ] Define a single exported provider discriminant union and provider adapter + map in `packages/ai` for Pi attempts: OpenRouter-compatible, opencode-go, and + any direct provider added later. +- [ ] Replace static `chatAttempts` call sites with per-Acting-User attempt + resolution: BYOK attempts first, service attempts only when no BYOK exists or + explicit service fallback is enabled. +- [ ] Update turn execution, compaction, attempt selection, and attempt logging + so provider/model selection is per turn and logs never include raw env or key + material. +- [ ] Make `generateImage` policy explicit in code/UI: service image provider + only unless an image-capable BYOK provider is configured. +- [ ] Add App Home Model Keys UI: add/rotate/delete credential, provider input, + model id input, optional base URL, status display, and masked key preview + only. +- [ ] Ensure Slack modal `private_metadata`, view state re-renders, prompt + hints, Pi session files, E2B env, task renderers, and logs never contain raw + API keys. +- [ ] Validate credentials on save or first use, store safe validation status, + and surface invalid-key/auth/quota errors only to the Acting User where Slack + allows. +- [ ] Decide service fallback UX: default off for BYOK failures, with explicit + opt-in if users may spend the shared service key after their key fails. +- [ ] Add tests for provider-to-`customEnv` mapping, BYOK-first fallback order, + service fallback disabled, invalid-key handling, App Home modal parsing, and + ownership checks. +- [ ] Live smoke: save a key, run a Slack turn, run compaction, rotate the key, + delete the key, trigger an invalid-key turn, and verify two users in one + thread use separate credentials without leaking provider/key details. + +### Provider Selection + +- [ ] Investigate why Gorkie sometimes does not switch models properly. +- [ ] Verify Langfuse receives AI SDK spans using `@ai-sdk/otel` plus + `LangfuseSpanProcessor`. Current note: it does not. + +## Verification Matrix + +- [ ] Add tests or harnessed smoke coverage for thread mention with 50-message + cap, channel mention with 20-message cap, DM follow-up, failed history fetch, + and attachment-skipping behavior. +- [ ] Live verify in Slack that Gorkie can answer a thread-context question from + the preload and can still call `readConversationHistory` for older or broader + context. + +## Upstream AI SDK And Harness Bets + +- [ ] Native MCP support and skill support. +- [ ] Native steering support exposing queued user messages or + `submitUserMessage` cleanly. +- [ ] Refactored Pi provider selection that is not ENV-prefix and + model-id-order dependent. +- [ ] `ai-retry` support at the Harness/Pi boundary so custom retry logic can be + deleted. +- [ ] Native Langfuse/OTel support deep enough for Harness/Pi model, tool, and + session internals. +- [ ] Official AI SDK E2B provider support with the resume/session-file hooks + Gorkie needs. +- [ ] Pi-level retry parity with the old implementation so transient provider + failures can retry inside Pi before Gorkie's outer attempt fallback runs. + +## V1 Reference TODOs + +Imported from `/workspaces/worktrees/gorkie-slack/reference/TODO.md`. These are +not automatically v2 requirements, but they capture bugs and product gaps found +in the old implementation. + +### Provider And Model Reliability + +- [ ] Add a configurable output cap for chat and sandbox model calls so provider + fallback requests do not reserve unaffordable default output budgets. The v1 + trace showed HackClub returning Cloudflare 504 HTML, then OpenRouter rejecting + fallback with a 402 because it reserved up to `65_536` output tokens. +- [ ] Add multi-provider retry for the Pi sandbox agent so coding-agent failures + can fall back to alternative providers instead of ending the task. +- [ ] Evaluate whether the primary chat model should move from Gemini Flash to + a smaller GPT-5 family model. Document cost, latency, context, and quality + tradeoffs before changing defaults. +- [ ] Remove the local `ai-retry` patch when upstream supports AI SDK 7 without + local declaration changes. Until then, keep AI SDK packages on the same v7 + canary line, keep `ai-retry` pinned, and smoke-test provider fallback after + provider or AI SDK bumps. + +### MCP And OAuth + +- [ ] Show manually denied tools in the thinking/task panel after approval + resume. Denied approvals need synthetic pre-tasks in the new response stream. +- [ ] Improve MCP tool task display. Show the normalized MCP tool name in the + title, such as `Using Fathom: list_meetings`, plus concise clamped input and + output previews. +- [ ] Lock down connection-defining MCP server fields after creation. URL, + transport, and auth type should require deleting and re-adding the server. +- [ ] Add scheduled MCP OAuth token refresh before expiry and record refresh + failures without breaking unrelated MCP servers. +- [ ] Add MCP tool discovery before full OAuth so users can preview available + tools in the Slack setup flow. +- [ ] When setting an entire MCP tool group, apply the change to the whole group, + not only the currently visible pagination page. Search results can stay scoped + to the visible results. + +### Slack Stream And Task UX + +- [ ] If a terminal tool fires without visible reasoning, show the terminal task + directly, such as `Replied`, `Skipping`, or `Left channel`, instead of a + generic thinking task. +- [ ] Investigate intermittent missing thinking/reasoning in Slack task streams. + Verify reasoning lifecycle events appear before tool calls and remain visible + after terminal tasks. +- [ ] Prevent Slack task cards from overflowing block limits. Start a + continuation task card when the current card approaches Slack limits. +- [ ] Make Slack search errors non-fatal so a search failure does not mark an + otherwise successful task as failed. +- [ ] Fix pinned-item detection for Slack search results. Confirm whether + user-specific pinning requires `pins.list`, `conversations.info`, or another + Slack API path. +- [ ] After sandbox upload tools run, inject the uploaded file list into the + model-visible response context so the agent knows what was uploaded. + +### Sandbox And Browser Tooling + +- [ ] Investigate why `agent-browser --snapshot /path/to/file.png` does not save + files where the sandbox agent expects them. Determine whether it writes + relative to the command CWD, daemon CWD, or a temp directory. +- [ ] Add `agentmail` to the E2B sandbox template or document it as a first-run + install for the `agent-browser` skill. + +### Cleanup And Package Shape + +- [ ] Clean up message pipeline utilities from the old Slack stack: + `message-context.ts`, `triggers.ts`, `conversations.ts`, and `context.ts`. + Remove duplicated `users.info` patterns and tighten stale types if any of + that code is reintroduced. +- [ ] Continue reducing schema and type clutter. Move reusable types into clear + type owners, keep Slack actions/views feature-owned, and avoid one-shot + helpers. +- [ ] Consider whether more package extraction is worth it, especially + observability and sandbox logic, if those areas become shared by bot and + server code. +- [ ] Build out `packages/kv` before any feature uses it. Add typed key builders + and query helpers, and decide whether serverless paths need Upstash instead + of a persistent Redis connection. + +### Workflow + +- [ ] Add an opt-in checkpoint flow for larger agent tasks. Commits must stay + scoped to the current task and avoid unrelated dirty worktree changes. +- [ ] If a planning thread exceeds roughly 50 messages, start a new planning + block to avoid context degradation. diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index c4f7766f..70c1ccb1 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -1,14 +1,12 @@ import type { Message, Thread } from 'chat'; -import { compactTurn, runTurn, stopTurn } from '@/lib/agent'; +import { runTurn, stopTurn } from '@/lib/agent'; import { isUserAllowed } from '@/lib/allowed-users'; import { bot, slack } from '@/lib/chat'; +import { handleCommand } from '@/lib/commands'; import logger from '@/lib/logger'; -import { - acceptOptIn, - OPT_IN_ACCEPT_ACTION, - offerOptIn, -} from '@/lib/onboarding'; +import { acceptOptIn, offerOptIn } from '@/lib/onboarding'; import { toLogError } from '@/lib/utils/error'; +import { rawText, withoutLeadingMentions } from '@/lib/utils/message'; import '@/features/assistant'; import '@/features/customizations'; @@ -59,7 +57,7 @@ bot.onSubscribedMessage(async (thread, message) => { await runCommandOrTurn(thread, message); }); -bot.onAction(OPT_IN_ACCEPT_ACTION, acceptOptIn); +bot.onAction('opt_in_accept', acceptOptIn); bot.onAction('stop_turn', async (event) => { const threadId = event.value ?? event.threadId; @@ -67,7 +65,7 @@ bot.onAction('stop_turn', async (event) => { if (!stopped) { await event.thread - ?.postEphemeral(event.user, 'No active response to stop.', { + ?.postEphemeral(event.user, 'no active response to stop.', { fallbackToDM: false, }) .catch((error: unknown) => { @@ -83,39 +81,11 @@ bot.onAction('stop_turn', async (event) => { } }); -// Leading `<@U...>` mentions Slack puts before the actual message body. -const LEADING_MENTIONS = /^\s*(?:<@[A-Z0-9][A-Z0-9._-]*(?:\|[^>]+)?>\s*)+/; -const COMPACT_COMMAND = /^!compact\b(.*)$/is; - -function rawText(message: Message): string { - const raw = message.raw; - return raw && - typeof raw === 'object' && - 'text' in raw && - typeof raw.text === 'string' - ? raw.text - : message.text; -} - -// A `!compact` command addressed to Gorkie returns its (possibly empty) custom -// summary instructions; anything else returns null. -function compactInstructions(message: Message): string | null { - const body = rawText(message).replace(LEADING_MENTIONS, '').trim(); - const match = body.match(COMPACT_COMMAND); - return match ? (match[1] ?? '').trim() : null; -} - async function runCommandOrTurn( thread: Thread, message: Message ): Promise { - const instructions = compactInstructions(message); - if (instructions !== null) { - await compactTurn({ - instructions: instructions || undefined, - message, - thread, - }); + if (await handleCommand({ message, thread })) { return; } await runTurn({ message, thread }); @@ -131,7 +101,7 @@ function shouldIgnore(message: Message): boolean { } for (const line of rawText(message).split('\n')) { - if (line.replace(LEADING_MENTIONS, '').trimStart().startsWith('##')) { + if (withoutLeadingMentions(line).trimStart().startsWith('##')) { return true; } } diff --git a/apps/bot/src/features/customizations/index.ts b/apps/bot/src/features/customizations/index.ts index ad03e099..3baff44f 100644 --- a/apps/bot/src/features/customizations/index.ts +++ b/apps/bot/src/features/customizations/index.ts @@ -15,7 +15,6 @@ import logger from '@/lib/logger'; import { toLogError } from '@/lib/utils/error'; import { openedViewSchema, - PROMPT_INPUT, parseModalState, promptFromViewValues, slackActionViewSchema, @@ -162,7 +161,7 @@ bot.onAction('home_clear_prompt', async (event) => { bot.onModalSubmit( ['home_save_prompt', 'home_save_preset_prompt'], async (event) => { - const prompt = event.values[PROMPT_INPUT]?.trim(); + const prompt = event.values.prompt?.trim(); if (prompt === undefined) { return { action: 'errors', diff --git a/apps/bot/src/features/customizations/schema.ts b/apps/bot/src/features/customizations/schema.ts index 372c43c7..fb21260b 100644 --- a/apps/bot/src/features/customizations/schema.ts +++ b/apps/bot/src/features/customizations/schema.ts @@ -1,7 +1,5 @@ import { z } from 'zod'; -export const PROMPT_INPUT = 'prompt'; - const modalStateSchema = z .object({ showPresets: z.boolean().default(false), @@ -56,6 +54,6 @@ export function promptFromViewValues({ return null; } - const input = parsed.data.customization_prompt?.[PROMPT_INPUT]; + const input = parsed.data.customization_prompt?.prompt; return typeof input?.value === 'string' ? input.value.trim() : null; } diff --git a/apps/bot/src/features/customizations/views.ts b/apps/bot/src/features/customizations/views.ts index c478b9e0..f49b1436 100644 --- a/apps/bot/src/features/customizations/views.ts +++ b/apps/bot/src/features/customizations/views.ts @@ -4,15 +4,9 @@ import { escapeSlackText, } from '@chat-adapter/slack/format'; import { personas } from '@repo/ai'; -import { PROMPT_INPUT } from './schema'; -import type { - SlackBlock, - SlackHomeView, - SlackModalView, - SlackTextInputElement, -} from './types'; +import type { SlackBlock, SlackHomeView, SlackModalView } from '@/types/views'; -const maxHomePromptLength = 900; +const maxHomePromptLength = 600; const maxPromptLength = 3000; export function buildHomeView({ @@ -82,20 +76,6 @@ export function buildPromptModal({ prompt: string | null; showPresets?: boolean; }): SlackModalView { - const input: SlackTextInputElement = { - action_id: PROMPT_INPUT, - max_length: maxPromptLength, - multiline: true, - placeholder: createSlackPlainText( - 'e.g. Keep responses concise. Prefer TypeScript. Call me Alex.' - ), - type: 'plain_text_input', - }; - - if (prompt) { - input.initial_value = prompt; - } - const presetBlocks: SlackBlock[] = showPresets ? personas.map((persona) => ({ accessory: { @@ -128,7 +108,16 @@ export function buildPromptModal({ { type: 'divider' }, { block_id: 'customization_prompt', - element: input, + element: { + action_id: 'prompt', + ...(prompt ? { initial_value: prompt } : {}), + max_length: maxPromptLength, + multiline: true, + placeholder: createSlackPlainText( + 'e.g. Keep responses concise. Prefer TypeScript. Call me Alex.' + ), + type: 'plain_text_input', + }, hint: createSlackPlainText( 'Gorkie follows these instructions across every conversation.' ), @@ -163,7 +152,7 @@ export function buildPresetModal({ { block_id: 'customization_prompt', element: { - action_id: PROMPT_INPUT, + action_id: 'prompt', initial_value: prompt, max_length: maxPromptLength, multiline: true, diff --git a/apps/bot/src/lib/agent/compaction.ts b/apps/bot/src/lib/agent/compaction.ts new file mode 100644 index 00000000..a1bc669c --- /dev/null +++ b/apps/bot/src/lib/agent/compaction.ts @@ -0,0 +1,83 @@ +import { + chatAttempts, + createAgent, + openSession, + persistSession, + type SandboxContext, + systemPrompt, +} from '@repo/ai'; +import { loadSkills } from '@repo/sandbox'; +import type { Message, Thread } from 'chat'; +import { sandbox } from '@/lib/agent/sandbox'; +import { requestHints } from '@/lib/ai/hints'; +import { buildTools } from '@/lib/ai/toolset'; +import { runQueuedTurn } from '@/lib/ai/turn-queue'; +import { bot } from '@/lib/chat'; +import { agentErrorMessage } from '@/lib/errors'; +import logger from '@/lib/logger'; + +export function compactTurn(input: { + instructions?: string; + message: Message; + thread: Thread; +}): Promise { + return runQueuedTurn({ + threadId: input.thread.id, + run: () => executeCompact(input), + }); +} + +async function executeCompact({ + instructions, + message, + thread, +}: { + instructions?: string; + message: Message; + thread: Thread; +}): Promise { + const threadId = thread.id; + const attempt = chatAttempts[0]; + if (!attempt) { + logger.error({ threadId }, '[agent] no model configured for compaction'); + return; + } + logger.info({ threadId }, '[agent] compaction started'); + await thread.startTyping('is compacting'); + + const hints = await requestHints({ thread, message }); + const skills = await loadSkills(); + let sandboxContext: SandboxContext | undefined; + const agent = createAgent({ + attempt, + onSandboxReady: (context) => { + sandboxContext = context; + }, + sandbox, + sessionId: threadId, + skills, + systemPrompt: systemPrompt({ hints }), + tools: buildTools({ + bot, + getSandboxContext: () => sandboxContext, + message, + thread, + }), + }); + + let session: Awaited> | undefined; + try { + session = await openSession({ agent, threadId }); + await session.compact(instructions); + await persistSession({ session, snapshotSource: sandboxContext, threadId }); + await sandbox.pauseSession({ threadId }); + logger.info({ threadId }, '[agent] compaction complete'); + await thread + .post({ markdown: "compacted this thread's context." }) + .catch(() => undefined); + } catch (error) { + logger.error({ err: error, threadId }, '[agent] compaction failed'); + await session?.detach().catch(() => undefined); + await thread.post(agentErrorMessage({ error })).catch(() => undefined); + } +} diff --git a/apps/bot/src/lib/agent/index.ts b/apps/bot/src/lib/agent/index.ts index 0d7550c1..1a946adf 100644 --- a/apps/bot/src/lib/agent/index.ts +++ b/apps/bot/src/lib/agent/index.ts @@ -7,145 +7,53 @@ import { type SandboxContext, systemPrompt, } from '@repo/ai'; -import { E2BSandboxProvider, loadSkills } from '@repo/sandbox'; +import { loadSkills } from '@repo/sandbox'; import { type Message, StreamingPlan, type Thread } from 'chat'; -import { env } from '@/env'; import { deleteControls, postControls } from '@/lib/agent/controls'; -import { createLineReply } from '@/lib/agent/line-reply'; -import { buildAgentPromptText } from '@/lib/agent/prompt'; +import { buildPrompt } from '@/lib/agent/prompt'; +import { createReply } from '@/lib/agent/reply'; +import { sandbox } from '@/lib/agent/sandbox'; import { - type ActiveTurn, abortReasonOf, interruptTurn, - pendingResumeInput, - TurnAbort, + queuedInput, } from '@/lib/agent/steering'; +import { clearTurn, getTurn, setTurn } from '@/lib/agent/turns'; import { startThinking } from '@/lib/agent/utils'; import { promptWithAttachments, seedAttachments } from '@/lib/ai/attachments'; -import { type AttemptFailure, nextAttempt } from '@/lib/ai/attempts'; +import { nextAttempt } from '@/lib/ai/attempts'; import { requestHints } from '@/lib/ai/hints'; import { renderStream } from '@/lib/ai/stream'; import { buildTools } from '@/lib/ai/toolset'; import { runQueuedTurn } from '@/lib/ai/turn-queue'; import { bot, slack } from '@/lib/chat'; -import { type AgentErrorStage, agentErrorMessage } from '@/lib/errors'; +import { agentErrorMessage } from '@/lib/errors'; import logger from '@/lib/logger'; import { errorMessage } from '@/lib/utils/error'; +import type { ActiveTurn, AgentErrorStage } from '@/types/agent'; +import type { AttemptFailure } from '@/types/attempts'; -const activeTurns = new Map(); - -const sandbox = new E2BSandboxProvider({ - apiKey: env.E2B_API_KEY, - env: { - ...(env.AGENTMAIL_API_KEY - ? { AGENTMAIL_API_KEY: env.AGENTMAIL_API_KEY } - : {}), - }, - logger, -}); +export { compactTurn } from '@/lib/agent/compaction'; +export { stopAllTurns, stopTurn } from '@/lib/agent/turns'; export function runTurn(input: { message: Message; thread: Thread; }): Promise { - const activeTurn = activeTurns.get(input.thread.id); - if (!activeTurn) { + const turn = getTurn({ threadId: input.thread.id }); + if (!turn) { return runQueuedTurn({ threadId: input.thread.id, run: (controller) => executeTurn(input, controller), }); } - interruptTurn({ activeTurn, input }); + interruptTurn({ activeTurn: turn, input }); return slack .addReaction(input.thread.id, input.message.id, 'white_check_mark') .catch(() => undefined); } -export function stopTurn({ threadId }: { threadId: string }): boolean { - const activeTurn = activeTurns.get(threadId); - if (!activeTurn) { - return false; - } - activeTurn.controller.abort(new TurnAbort('stop')); - return true; -} - -export function stopAllTurns(): void { - for (const activeTurn of activeTurns.values()) { - activeTurn.controller.abort(new TurnAbort('shutdown')); - } -} - -// Compaction is queued like a turn so it never races an in-flight response on -// the same thread; the runtime (Pi) compacts its session in place, and we -// persist the smaller transcript so the next turn resumes from it. -export function compactTurn(input: { - instructions?: string; - message: Message; - thread: Thread; -}): Promise { - return runQueuedTurn({ - threadId: input.thread.id, - run: () => executeCompact(input), - }); -} - -async function executeCompact({ - instructions, - message, - thread, -}: { - instructions?: string; - message: Message; - thread: Thread; -}): Promise { - const threadId = thread.id; - const attempt = chatAttempts[0]; - if (!attempt) { - logger.error({ threadId }, '[agent] no model configured for compaction'); - return; - } - logger.info({ threadId }, '[agent] compaction started'); - await thread.startTyping('is compacting'); - - const hints = await requestHints({ thread, message }); - const skills = await loadSkills(); - let sandboxContext: SandboxContext | undefined; - const agent = createAgent({ - attempt, - onSandboxReady: (context) => { - sandboxContext = context; - }, - sandbox, - sessionId: threadId, - skills, - systemPrompt: systemPrompt({ hints }), - tools: buildTools({ - bot, - getSandboxContext: () => sandboxContext, - message, - thread, - }), - }); - - let session: Awaited> | undefined; - try { - session = await openSession({ agent, threadId }); - await session.compact(instructions); - await persistSession({ session, snapshotSource: sandboxContext, threadId }); - await sandbox.pauseSession({ threadId }); - logger.info({ threadId }, '[agent] compaction complete'); - await thread - .post({ markdown: '🧹 Compacted this thread’s context.' }) - .catch(() => undefined); - } catch (error) { - logger.error({ err: error, threadId }, '[agent] compaction failed'); - await session?.detach().catch(() => undefined); - await thread.post(agentErrorMessage({ error })).catch(() => undefined); - } -} - async function executeTurn( { message, thread }: { message: Message; thread: Thread }, controller: AbortController @@ -156,7 +64,7 @@ async function executeTurn( controller, pendingMessages: [], }; - activeTurns.set(threadId, activeTurn); + setTurn({ threadId, turn: activeTurn }); await startThinking({ thread }); const hints = await requestHints({ thread, message }); @@ -164,8 +72,7 @@ async function executeTurn( let activeAttempt: PiAttempt | undefined; let controls: Awaited> = null; let sandboxContext: SandboxContext | undefined; - let completion: { finishReason: string; textLength: number } | undefined; - let lineReply: ReturnType | undefined; + let reply: ReturnType | undefined; let errorStage: AgentErrorStage = 'before_output'; const parkSession = async ({ pause }: { pause: boolean }): Promise => { @@ -191,12 +98,10 @@ async function executeTurn( groupTasks: 'plan', }) ); - if (!(session && completion)) { - throw new Error( - 'Agent turn ended before session completion was recorded.' - ); + if (!session) { + throw new Error('Agent turn ended before session was recorded.'); } - await lineReply?.flush({ thread }); + await reply?.flush({ thread }); if (hints.customization?.prompt && !slack.isDM(thread.id)) { await thread .post({ @@ -208,7 +113,7 @@ async function executeTurn( await deleteControls({ controls }); await parkSession({ pause: true }); logger.info( - { ...completion, attempt: attemptLog(activeAttempt), threadId }, + { attempt: attemptLog(activeAttempt), threadId }, '[agent] turn complete' ); } catch (error) { @@ -225,20 +130,18 @@ async function executeTurn( { attempt: attemptLog(activeAttempt), err: error, threadId }, '[agent] turn failed' ); - await lineReply?.flush({ thread }); + await reply?.flush({ thread }); await parkSession({ pause: true }); await deleteControls({ controls }); await thread.post(agentErrorMessage({ error, stage: errorStage })); } } finally { - if (activeTurns.get(threadId) === activeTurn) { - activeTurns.delete(threadId); - } + clearTurn({ threadId, turn: activeTurn }); // Only an interrupt replays queued messages; a rapid burst is merged into a // single follow-up so steering does not drop intermediate corrections. const resume = abortReasonOf(controller.signal) === 'interrupt' - ? pendingResumeInput(activeTurn) + ? queuedInput(activeTurn) : undefined; if (resume) { runTurn(resume).catch((error: unknown) => { @@ -258,10 +161,12 @@ async function executeTurn( thread: Thread; }) { const skills = await loadSkills(); - const messageText = await buildAgentPromptText(message); + const messageText = await buildPrompt(message, { + customizationPrompt: hints.customization?.prompt, + }); let attachments: Awaited> = []; - let hasStreamed = false; - const attemptHistory: AttemptFailure[] = []; + let streamed = false; + const attempts: AttemptFailure[] = []; let attempt = chatAttempts[0]; while (attempt) { const currentAttempt = attempt; @@ -292,7 +197,7 @@ async function executeTurn( }), }); session = await openSession({ agent, threadId }); - lineReply = createLineReply({ threadId }); + reply = createReply({ threadId }); const result = await agent.stream({ abortSignal: controller.signal, prompt: promptWithAttachments({ @@ -303,14 +208,14 @@ async function executeTurn( }); for await (const chunk of renderStream({ onTextDelta: async (text) => { - hasStreamed = true; + streamed = true; errorStage = 'after_text'; controls ??= await postControls({ thread }); - await lineReply?.append({ text, thread }); + await reply?.append({ text, thread }); }, stream: result.stream, })) { - hasStreamed = true; + streamed = true; if (errorStage === 'before_output') { errorStage = 'after_progress'; } @@ -318,30 +223,14 @@ async function executeTurn( controls ??= await postControls({ thread }); } - try { - const [text, finishReason] = await Promise.all([ - result.text, - result.finishReason, - ]); - completion = { finishReason, textLength: text.length }; - } catch (error) { - if (!hasStreamed) { - throw error; - } - logger.warn( - { err: errorMessage(error), threadId }, - '[agent] failed to read final stream metadata after text output' - ); - completion = { finishReason: 'unknown', textLength: 0 }; - } return; } catch (error) { - attemptHistory.push({ attempt: currentAttempt, error }); + attempts.push({ attempt: currentAttempt, error }); const retryAttempt = nextAttempt({ attempts: chatAttempts, - failures: attemptHistory, + failures: attempts, }); - if (controller.signal.aborted || hasStreamed || !retryAttempt) { + if (controller.signal.aborted || streamed || !retryAttempt) { throw error; } logger.warn( diff --git a/apps/bot/src/lib/agent/mentions.ts b/apps/bot/src/lib/agent/mentions.ts new file mode 100644 index 00000000..2b88f77f --- /dev/null +++ b/apps/bot/src/lib/agent/mentions.ts @@ -0,0 +1,36 @@ +import { bot } from '@/lib/chat'; + +const mentionPattern = /<@([A-Z0-9_]+)(?:\|([^<>]+))?>/g; + +export async function annotateMentions(text: string): Promise { + const mentionNames = new Map(); + const missingIds = new Set(); + + for (const mention of text.matchAll(mentionPattern)) { + const userId = mention[1]; + const label = mention[2]; + if (!userId || mentionNames.has(userId)) { + continue; + } + if (label) { + mentionNames.set(userId, label); + continue; + } + missingIds.add(userId); + } + + await Promise.all( + [...missingIds].map(async (userId) => { + const user = await bot.getUser(userId).catch(() => undefined); + mentionNames.set(userId, user?.userName ?? userId); + }) + ); + + if (mentionNames.size === 0) { + return text; + } + return text.replace(mentionPattern, (token, userId: string) => { + const name = mentionNames.get(userId); + return name ? `@${name} (${userId})` : token; + }); +} diff --git a/apps/bot/src/lib/agent/prompt.ts b/apps/bot/src/lib/agent/prompt.ts index 167469f0..d29d5b08 100644 --- a/apps/bot/src/lib/agent/prompt.ts +++ b/apps/bot/src/lib/agent/prompt.ts @@ -1,56 +1,30 @@ import { slackMrkdwnToMarkdown } from '@chat-adapter/slack/format'; import type { Message } from 'chat'; -import { bot } from '@/lib/chat'; +import { annotateMentions } from '@/lib/agent/mentions'; +import { rawSlackText } from '@/lib/utils/message'; -const slackUserMentionPattern = /<@([A-Z0-9_]+)(?:\|([^<>]+))?>/g; +export async function buildPrompt( + message: Message, + { + customizationPrompt, + }: { + customizationPrompt?: string; + } = {} +): Promise { + const prefix = `@${message.author.userName} (${message.author.userId})`; + const slackText = rawSlackText(message); + const text = slackText + ? slackMrkdwnToMarkdown(await annotateMentions(slackText)) + : message.text; + const messageText = `${prefix}: ${text}`; -export async function buildAgentPromptText(message: Message): Promise { - const raw = message.raw; - const rawText = - raw && - typeof raw === 'object' && - 'text' in raw && - typeof raw.text === 'string' - ? raw.text - : undefined; - const prefix = `[${message.author.userName}: ${message.author.userId}]`; - if (!rawText) { - return `${prefix}: ${message.text}`; - } - - const mentions = [...rawText.matchAll(slackUserMentionPattern)]; - if (mentions.length === 0) { - return `${prefix}: ${slackMrkdwnToMarkdown(rawText)}`; - } - - const mentionNames = new Map(); - const mentionLookups: Promise[] = []; - - for (const mention of mentions) { - const userId = mention[1]; - const label = mention[2]; - if (!userId || mentionNames.has(userId)) { - continue; - } - if (label) { - mentionNames.set(userId, label); - continue; - } - mentionLookups.push( - bot.getUser(userId).then((user) => { - mentionNames.set(userId, user?.userName ?? userId); - }) - ); - } - - await Promise.all(mentionLookups); - const annotatedText = rawText.replace( - slackUserMentionPattern, - (token, userId: string) => { - const name = mentionNames.get(userId); - return name ? `@${name} [${userId}]` : token; - } - ); - - return `${prefix}: ${slackMrkdwnToMarkdown(annotatedText)}`; + return customizationPrompt + ? [ + '', + customizationPrompt, + '', + '', + messageText, + ].join('\n') + : messageText; } diff --git a/apps/bot/src/lib/agent/line-reply.ts b/apps/bot/src/lib/agent/reply.ts similarity index 97% rename from apps/bot/src/lib/agent/line-reply.ts rename to apps/bot/src/lib/agent/reply.ts index 6f7acd70..493b74b2 100644 --- a/apps/bot/src/lib/agent/line-reply.ts +++ b/apps/bot/src/lib/agent/reply.ts @@ -16,7 +16,7 @@ const IDLE_MS = 1500; const FENCE_REOPEN_PADDING = 3; const TABLE_SEPARATOR = /^\|?[\s:|-]*-{2,}[\s:|-]*$/; -export function createLineReply({ threadId }: { threadId: string }) { +export function createReply({ threadId }: { threadId: string }) { let buffer = ''; let lastPostAt = Date.now(); @@ -42,7 +42,7 @@ export function createLineReply({ threadId }: { threadId: string }) { lastPostAt = Date.now(); }) .catch((error: unknown) => { - logger.warn({ err: error, threadId }, '[agent] line reply failed'); + logger.warn({ err: error, threadId }, '[agent] reply post failed'); }); } } diff --git a/apps/bot/src/lib/agent/sandbox.ts b/apps/bot/src/lib/agent/sandbox.ts new file mode 100644 index 00000000..fd0ecec5 --- /dev/null +++ b/apps/bot/src/lib/agent/sandbox.ts @@ -0,0 +1,13 @@ +import { E2BSandboxProvider } from '@repo/sandbox'; +import { env } from '@/env'; +import logger from '@/lib/logger'; + +export const sandbox = new E2BSandboxProvider({ + apiKey: env.E2B_API_KEY, + env: { + ...(env.AGENTMAIL_API_KEY + ? { AGENTMAIL_API_KEY: env.AGENTMAIL_API_KEY } + : {}), + }, + logger, +}); diff --git a/apps/bot/src/lib/agent/steering.ts b/apps/bot/src/lib/agent/steering.ts index 465aa94a..1e7ec23a 100644 --- a/apps/bot/src/lib/agent/steering.ts +++ b/apps/bot/src/lib/agent/steering.ts @@ -1,14 +1,6 @@ -import { Message, parseMarkdown, type Thread } from 'chat'; +import { Message, parseMarkdown } from 'chat'; +import type { AbortReason, ActiveTurn, TurnInput } from '@/types/agent'; -export interface TurnInput { - message: Message; - thread: Thread; -} - -export type AbortReason = 'interrupt' | 'stop' | 'shutdown'; - -// Carried as the AbortSignal reason so the turn loop knows why it was aborted: -// an `interrupt` restarts with the queued follow-up; `stop`/`shutdown` do not. export class TurnAbort extends Error { readonly reason: AbortReason; constructor(reason: AbortReason) { @@ -18,18 +10,12 @@ export class TurnAbort extends Error { } } -export interface ActiveTurn { - controller: AbortController; - pendingMessages: TurnInput[]; -} - export function abortReasonOf(signal: AbortSignal): AbortReason | undefined { if (!signal.aborted) { return; } - return signal.reason instanceof TurnAbort - ? signal.reason.reason - : 'interrupt'; + const { reason } = signal; + return reason instanceof TurnAbort ? reason.reason : 'interrupt'; } export function interruptTurn({ @@ -43,9 +29,7 @@ export function interruptTurn({ activeTurn.controller.abort(new TurnAbort('interrupt')); } -export function pendingResumeInput( - activeTurn: ActiveTurn -): TurnInput | undefined { +export function queuedInput(activeTurn: ActiveTurn): TurnInput | undefined { const latest = activeTurn.pendingMessages.at(-1); if (!latest) { return; diff --git a/apps/bot/src/lib/agent/turns.ts b/apps/bot/src/lib/agent/turns.ts new file mode 100644 index 00000000..95d505b9 --- /dev/null +++ b/apps/bot/src/lib/agent/turns.ts @@ -0,0 +1,49 @@ +import { TurnAbort } from '@/lib/agent/steering'; +import type { ActiveTurn } from '@/types/agent'; + +const turns = new Map(); + +export function getTurn({ + threadId, +}: { + threadId: string; +}): ActiveTurn | undefined { + return turns.get(threadId); +} + +export function setTurn({ + threadId, + turn, +}: { + threadId: string; + turn: ActiveTurn; +}): void { + turns.set(threadId, turn); +} + +export function clearTurn({ + threadId, + turn, +}: { + threadId: string; + turn: ActiveTurn; +}): void { + if (turns.get(threadId) === turn) { + turns.delete(threadId); + } +} + +export function stopTurn({ threadId }: { threadId: string }): boolean { + const turn = turns.get(threadId); + if (!turn) { + return false; + } + turn.controller.abort(new TurnAbort('stop')); + return true; +} + +export function stopAllTurns(): void { + for (const turn of turns.values()) { + turn.controller.abort(new TurnAbort('shutdown')); + } +} diff --git a/apps/bot/src/lib/ai/attachments.ts b/apps/bot/src/lib/ai/attachments.ts index 3a6e823d..dc48fa9a 100644 --- a/apps/bot/src/lib/ai/attachments.ts +++ b/apps/bot/src/lib/ai/attachments.ts @@ -2,13 +2,7 @@ import nodePath from 'node:path/posix'; import type { SandboxContext } from '@repo/ai'; import type { Message } from 'chat'; import { sanitizeFilename } from '@/lib/utils/sanitize'; - -export interface SeededAttachment { - mimeType?: string; - name: string; - path: string; - type: string; -} +import type { SeededAttachment } from '@/types/attachments'; export async function seedAttachments({ message, @@ -18,43 +12,55 @@ export async function seedAttachments({ sandboxContext: SandboxContext; }): Promise { const seeded = await Promise.all( - message.attachments.map( - async (attachment, index): Promise => { - const data = attachment.fetchData - ? await attachment.fetchData() - : attachment.data; - if (!data) { - return null; - } - - const fallback = `attachment-${index + 1}`; - const filename = - sanitizeFilename(nodePath.basename(attachment.name || fallback)) || - fallback; - const messageDir = sanitizeFilename(message.id) || 'message'; - const path = nodePath.join( - sandboxContext.sessionWorkDir, - 'attachments', - messageDir, - filename - ); - const bytes = - data instanceof Blob - ? new Uint8Array(await data.arrayBuffer()) - : new Uint8Array(data); - await sandboxContext.session.writeBinaryFile({ content: bytes, path }); - return { - mimeType: attachment.mimeType, - name: filename, - path, - type: attachment.type, - }; - } + message.attachments.map((attachment, index) => + seedAttachment({ attachment, index, message, sandboxContext }) ) ); return seeded.filter((entry): entry is SeededAttachment => entry !== null); } +async function seedAttachment({ + attachment, + index, + message, + sandboxContext, +}: { + attachment: Message['attachments'][number]; + index: number; + message: Message; + sandboxContext: SandboxContext; +}): Promise { + const data = attachment.fetchData + ? await attachment.fetchData() + : attachment.data; + if (!data) { + return null; + } + + const fallback = `attachment-${index + 1}`; + const filename = + sanitizeFilename(nodePath.basename(attachment.name || fallback)) || + fallback; + const messageDir = sanitizeFilename(message.id) || 'message'; + const path = nodePath.join( + sandboxContext.sessionWorkDir, + 'attachments', + messageDir, + filename + ); + const bytes = + data instanceof Blob + ? new Uint8Array(await data.arrayBuffer()) + : new Uint8Array(data); + await sandboxContext.session.writeBinaryFile({ content: bytes, path }); + return { + mimeType: attachment.mimeType, + name: filename, + path, + type: attachment.type, + }; +} + export function promptWithAttachments({ attachments, text, diff --git a/apps/bot/src/lib/ai/attempts.ts b/apps/bot/src/lib/ai/attempts.ts index 465d081c..4a7db7de 100644 --- a/apps/bot/src/lib/ai/attempts.ts +++ b/apps/bot/src/lib/ai/attempts.ts @@ -1,9 +1,5 @@ import type { PiAttempt } from '@repo/ai'; - -export interface AttemptFailure { - attempt: PiAttempt; - error: unknown; -} +import type { AttemptFailure } from '@/types/attempts'; export function nextAttempt({ attempts, diff --git a/apps/bot/src/lib/ai/stream/index.ts b/apps/bot/src/lib/ai/stream/index.ts index 6717031f..e8654846 100644 --- a/apps/bot/src/lib/ai/stream/index.ts +++ b/apps/bot/src/lib/ai/stream/index.ts @@ -2,7 +2,7 @@ import type { TextStreamPart, ToolSet } from 'ai'; import type { StreamChunk } from 'chat'; import logger from '@/lib/logger'; import { clamp } from '@/lib/utils/text'; -import { renderToolTask } from './tasks'; +import { renderTask } from './tasks'; const MAX_VISIBLE_TASKS = 45; const REASONING_OUTPUT_MAX_LENGTH = 2800; @@ -27,9 +27,15 @@ export async function* renderStream({ break; } case 'reasoning-start': { - reasoning.set(part.id, ''); + const id = reasoningTaskId(part.id); + if (!showTask({ id, visibleTaskIds })) { + hiddenTaskCount += 1; + yield hiddenTaskUpdate({ count: hiddenTaskCount, done: false }); + break; + } + reasoning.set(id, ''); yield { - id: `reasoning-${part.id}`, + id, status: 'in_progress', title: 'Thinking', type: 'task_update', @@ -37,19 +43,26 @@ export async function* renderStream({ break; } case 'reasoning-delta': { - reasoning.set(part.id, (reasoning.get(part.id) ?? '') + part.text); + const id = reasoningTaskId(part.id); + if (visibleTaskIds.has(id)) { + reasoning.set(id, (reasoning.get(id) ?? '') + part.text); + } break; } case 'reasoning-end': { - const text = reasoning.get(part.id)?.trim(); + const id = reasoningTaskId(part.id); + if (!visibleTaskIds.has(id)) { + break; + } + const text = reasoning.get(id)?.trim(); yield { - id: `reasoning-${part.id}`, + id, output: text ? clamp(text, REASONING_OUTPUT_MAX_LENGTH) : undefined, status: 'complete', title: 'Thinking', type: 'task_update', }; - reasoning.delete(part.id); + reasoning.delete(id); break; } case 'tool-call': { @@ -62,7 +75,7 @@ export async function* renderStream({ }, '[tool] called' ); - const rendered = renderToolTask({ + const rendered = renderTask({ input: part.input, phase: 'request', toolName: part.toolName, @@ -92,7 +105,7 @@ export async function* renderStream({ }, '[tool] completed' ); - const rendered = renderToolTask({ + const rendered = renderTask({ input, output: part.output, phase: 'response', @@ -122,7 +135,7 @@ export async function* renderStream({ }, '[tool] failed' ); - const rendered = renderToolTask({ + const rendered = renderTask({ input, output: part.error, phase: 'error', @@ -167,6 +180,10 @@ function showTask({ return false; } +function reasoningTaskId(id: string): string { + return `reasoning-${id}`; +} + function hiddenTaskUpdate({ count, done, @@ -175,12 +192,12 @@ function hiddenTaskUpdate({ done: boolean; }): StreamChunk { return { - id: 'hidden-tool-activity', + id: 'hidden-activity', output: done - ? `Ran ${count} additional tool${count === 1 ? '' : 's'}.` + ? `Ran ${count} additional activity item${count === 1 ? '' : 's'}.` : undefined, status: done ? 'complete' : 'in_progress', - title: `Tool activity: ${count}`, + title: `Activity: ${count}`, type: 'task_update', }; } diff --git a/apps/bot/src/lib/ai/stream/tasks/chat.ts b/apps/bot/src/lib/ai/stream/tasks/chat.ts index 06e3421d..c1193ae9 100644 --- a/apps/bot/src/lib/ai/stream/tasks/chat.ts +++ b/apps/bot/src/lib/ai/stream/tasks/chat.ts @@ -1,49 +1,24 @@ -import { - arrayLength, - booleanField, - numberField, - plural, - textField, -} from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { arraySize, bool, number, plural, text } from './helpers'; -export const message: ToolTaskRendererEntry = { +export const message: TaskRendererEntry = { request: ({ input }) => ({ - details: - textField(input, 'threadId') ?? - textField(input, 'channelId') ?? - textField(input, 'userId'), + details: text(input, 'id'), }), response: ({ output }) => ({ - output: `Sent message${textField(output, 'threadId') ? ` in ${textField(output, 'threadId')}` : ''}.`, + output: `Sent message${text(output, 'threadId') ? ` in ${text(output, 'threadId')}` : ''}.`, title: 'Sent message', }), title: 'Sending message', }; -export const directMessage: ToolTaskRendererEntry = { +export const fetchMessages: TaskRendererEntry = { request: ({ input }) => ({ - details: textField(input, 'userId'), + details: text(input, 'threadId') ?? text(input, 'channelId'), }), response: ({ input, output }) => { - const userId = textField(input, 'userId'); - const threadId = textField(output, 'threadId'); - return { - output: `Sent DM${userId ? ` to ${userId}` : ''}${threadId ? ` in ${threadId}` : ''}.`, - title: 'Sent DM', - }; - }, - title: 'Sending DM', -}; - -export const fetchMessages: ToolTaskRendererEntry = { - request: ({ input }) => ({ - details: textField(input, 'threadId') ?? textField(input, 'channelId'), - }), - response: ({ input, output }) => { - const count = arrayLength(output, 'messages') ?? 0; - const target = - textField(input, 'threadId') ?? textField(input, 'channelId'); + const count = arraySize(output, 'messages') ?? 0; + const target = text(input, 'threadId') ?? text(input, 'channelId'); return { output: count === 0 && target @@ -55,9 +30,9 @@ export const fetchMessages: ToolTaskRendererEntry = { title: 'Reading messages', }; -export const listThreads: ToolTaskRendererEntry = { +export const listThreads: TaskRendererEntry = { response: ({ output }) => { - const count = arrayLength(output, 'threads') ?? 0; + const count = arraySize(output, 'threads') ?? 0; return { output: `Found ${plural(count, 'thread')}.`, title: 'Listed threads', @@ -66,13 +41,13 @@ export const listThreads: ToolTaskRendererEntry = { title: 'Listing threads', }; -export const getUser: ToolTaskRendererEntry = { +export const getUser: TaskRendererEntry = { response: ({ input, output }) => { const name = - textField(output, 'userName') ?? - textField(output, 'fullName') ?? - textField(input, 'userId'); - const pronouns = textField(output, 'pronouns'); + text(output, 'userName') ?? + text(output, 'fullName') ?? + text(input, 'userId'); + const pronouns = text(output, 'pronouns'); return { output: name ? `Found ${name}${pronouns ? ` (${pronouns})` : ''}.` @@ -83,11 +58,10 @@ export const getUser: ToolTaskRendererEntry = { title: 'Looking up user', }; -export const getChannelInfo: ToolTaskRendererEntry = { +export const getChannelInfo: TaskRendererEntry = { response: ({ output }) => { - const name = - textField(output, 'name') ?? textField(output, 'id') ?? 'channel'; - const members = numberField(output, 'memberCount'); + const name = text(output, 'name') ?? text(output, 'id') ?? 'channel'; + const members = number(output, 'memberCount'); return { output: `${name}${members === undefined ? '' : ` · ${plural(members, 'member')}`}.`, title: 'Read channel', @@ -96,14 +70,13 @@ export const getChannelInfo: ToolTaskRendererEntry = { title: 'Reading channel', }; -export const reaction: ToolTaskRendererEntry = { +export const reaction: TaskRendererEntry = { request: ({ input }) => ({ - details: textField(input, 'emoji') ?? textField(input, 'messageId'), + details: text(input, 'emoji') ?? text(input, 'messageId'), }), response: ({ input, output }) => { - const emoji = - textField(output, 'emoji') ?? textField(input, 'emoji') ?? 'reaction'; - const added = booleanField(output, 'added'); + const emoji = text(output, 'emoji') ?? text(input, 'emoji') ?? 'reaction'; + const added = bool(output, 'added'); const action = added === false ? 'Could not update' : 'Added'; return { output: `${action} :${emoji}:.`, diff --git a/apps/bot/src/lib/ai/stream/tasks/default.ts b/apps/bot/src/lib/ai/stream/tasks/default.ts index 090953d4..c9b9b8e9 100644 --- a/apps/bot/src/lib/ai/stream/tasks/default.ts +++ b/apps/bot/src/lib/ai/stream/tasks/default.ts @@ -1,14 +1,14 @@ -import { errorOutput, textField } from './helpers'; -import type { DefaultToolTaskRenderer } from './types'; +import type { DefaultTaskRenderer } from '@/types/task-renderers'; +import { errorOutput, text } from './helpers'; -export const defaultTool: DefaultToolTaskRenderer = { +export const defaultTool: DefaultTaskRenderer = { request: ({ input, toolName }) => { const detail = - textField(input, 'command') ?? - textField(input, 'file_path') ?? - textField(input, 'pattern') ?? - textField(input, 'path') ?? - textField(input, 'query'); + text(input, 'command') ?? + text(input, 'file_path') ?? + text(input, 'pattern') ?? + text(input, 'path') ?? + text(input, 'query'); return { details: detail ? `${toolName}: ${detail}` : undefined, }; @@ -16,8 +16,8 @@ export const defaultTool: DefaultToolTaskRenderer = { response: ({ output }) => ({ output: (typeof output === 'string' && output.trim() ? output : undefined) ?? - textField(output, 'text') ?? - textField(output, 'error') ?? + text(output, 'text') ?? + text(output, 'error') ?? 'Completed.', }), error: ({ output }) => ({ diff --git a/apps/bot/src/lib/ai/stream/tasks/generate-image.ts b/apps/bot/src/lib/ai/stream/tasks/generate-image.ts index 9c9978b9..dd124e59 100644 --- a/apps/bot/src/lib/ai/stream/tasks/generate-image.ts +++ b/apps/bot/src/lib/ai/stream/tasks/generate-image.ts @@ -1,13 +1,13 @@ -import { numberField, plural, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { number, plural, text } from './helpers'; -export const generateImage: ToolTaskRendererEntry = { +export const generateImage: TaskRendererEntry = { title: 'Generating image', request: ({ input }) => ({ - details: textField(input, 'prompt'), + details: text(input, 'prompt'), }), response: ({ output }) => { - const uploaded = numberField(output, 'uploaded') ?? 0; + const uploaded = number(output, 'uploaded') ?? 0; return { output: `Uploaded ${plural(uploaded, 'image')}.`, title: uploaded > 0 ? 'Generated image' : 'Image generation finished', diff --git a/apps/bot/src/lib/ai/stream/tasks/get-file.ts b/apps/bot/src/lib/ai/stream/tasks/get-file.ts index b1dbb4f0..3a32df6c 100644 --- a/apps/bot/src/lib/ai/stream/tasks/get-file.ts +++ b/apps/bot/src/lib/ai/stream/tasks/get-file.ts @@ -1,11 +1,11 @@ -import { textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { text } from './helpers'; -export const getFile: ToolTaskRendererEntry = { +export const getFile: TaskRendererEntry = { title: 'Downloading file', - request: ({ input }) => ({ details: textField(input, 'file') }), + request: ({ input }) => ({ details: text(input, 'file') }), response: ({ output }) => ({ - output: `Downloaded ${textField(output, 'filename') ?? 'file'}.`, + output: `Downloaded ${text(output, 'filename') ?? 'file'}.`, title: 'Downloaded file', }), }; diff --git a/apps/bot/src/lib/ai/stream/tasks/helpers.ts b/apps/bot/src/lib/ai/stream/tasks/helpers.ts index 14bb14e9..962a6a85 100644 --- a/apps/bot/src/lib/ai/stream/tasks/helpers.ts +++ b/apps/bot/src/lib/ai/stream/tasks/helpers.ts @@ -1,27 +1,27 @@ -export function field(input: unknown, key: string): unknown { +export function value(input: unknown, key: string): unknown { return input && typeof input === 'object' && key in input ? Reflect.get(input, key) : undefined; } -export function textField(input: unknown, key: string): string | undefined { - const value = field(input, key); - return typeof value === 'string' && value ? value : undefined; +export function text(input: unknown, key: string): string | undefined { + const raw = value(input, key); + return typeof raw === 'string' && raw ? raw : undefined; } -export function numberField(input: unknown, key: string): number | undefined { - const value = field(input, key); - return typeof value === 'number' ? value : undefined; +export function number(input: unknown, key: string): number | undefined { + const raw = value(input, key); + return typeof raw === 'number' ? raw : undefined; } -export function booleanField(input: unknown, key: string): boolean | undefined { - const value = field(input, key); - return typeof value === 'boolean' ? value : undefined; +export function bool(input: unknown, key: string): boolean | undefined { + const raw = value(input, key); + return typeof raw === 'boolean' ? raw : undefined; } -export function arrayLength(input: unknown, key: string): number | undefined { - const value = field(input, key); - return Array.isArray(value) ? value.length : undefined; +export function arraySize(input: unknown, key: string): number | undefined { + const raw = value(input, key); + return Array.isArray(raw) ? raw.length : undefined; } export function plural( @@ -39,7 +39,7 @@ export function errorOutput(output: unknown): string | undefined { if (typeof output === 'string') { return `**Error**: ${output.replace(/^Error:\s*/, '')}`; } - const message = textField(output, 'message') ?? textField(output, 'error'); + const message = text(output, 'message') ?? text(output, 'error'); if (!message) { return '**Error**: tool failed'; } diff --git a/apps/bot/src/lib/ai/stream/tasks/index.ts b/apps/bot/src/lib/ai/stream/tasks/index.ts index da2c52df..e680fa8e 100644 --- a/apps/bot/src/lib/ai/stream/tasks/index.ts +++ b/apps/bot/src/lib/ai/stream/tasks/index.ts @@ -1,6 +1,6 @@ import { clamp } from '@/lib/utils/text'; +import type { TaskRendererEntry } from '@/types/task-renderers'; import { - directMessage, fetchMessages, getChannelInfo, getUser, @@ -19,13 +19,11 @@ import { scheduleReminder } from './schedule-reminder'; import { searchSlack } from './search-slack'; import { searchWeb } from './search-web'; import { summarizeThread } from './summarize-thread'; -import type { ToolTaskRendererEntry } from './types'; import { uploadFile } from './upload-file'; type RenderPhase = 'request' | 'response' | 'error'; -const toolRenderers: Record = { - addReaction: reaction, +const renderers: Record = { bash: command, compaction: { title: 'Compacting context' }, edit: { ...file, title: 'Editing file' }, @@ -40,20 +38,19 @@ const toolRenderers: Record = { listThreads, ls: { title: 'Listing files' }, mermaid, - postChannelMessage: { ...message, title: 'Posting to channel' }, postMessage: message, readConversationHistory: { ...fetchMessages, title: 'Reading history' }, + react: reaction, read: file, scheduleReminder, searchSlack, searchWeb, - sendDirectMessage: directMessage, summarizeThread, uploadFile, write: { ...file, title: 'Writing file' }, }; -export function renderToolTask({ +export function renderTask({ input, output, phase, @@ -64,7 +61,7 @@ export function renderToolTask({ phase: RenderPhase; toolName: string; }) { - const entry = toolRenderers[toolName]; + const entry = renderers[toolName]; const renderer = phase === 'error' ? defaultTool.error diff --git a/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts b/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts index 48f13e1d..9e913a69 100644 --- a/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts +++ b/apps/bot/src/lib/ai/stream/tasks/leave-thread.ts @@ -1,6 +1,6 @@ -import type { ToolTaskRendererEntry } from './types'; +import type { TaskRendererEntry } from '@/types/task-renderers'; -export const leaveThread: ToolTaskRendererEntry = { +export const leaveThread: TaskRendererEntry = { title: 'Leaving thread', response: () => ({ output: 'Left the thread.', diff --git a/apps/bot/src/lib/ai/stream/tasks/mermaid.ts b/apps/bot/src/lib/ai/stream/tasks/mermaid.ts index 7bb11b95..9aa35561 100644 --- a/apps/bot/src/lib/ai/stream/tasks/mermaid.ts +++ b/apps/bot/src/lib/ai/stream/tasks/mermaid.ts @@ -1,14 +1,14 @@ -import { textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { text } from './helpers'; -export const mermaid: ToolTaskRendererEntry = { +export const mermaid: TaskRendererEntry = { title: 'Creating diagram', request: ({ input }) => { - const detail = textField(input, 'title') ?? textField(input, 'code'); + const detail = text(input, 'title') ?? text(input, 'code'); return { details: detail }; }, response: ({ output }) => { - const error = textField(output, 'error'); + const error = text(output, 'error'); if (error) { return { output: `Error: ${error}`, @@ -16,7 +16,7 @@ export const mermaid: ToolTaskRendererEntry = { }; } return { - output: `Uploaded ${textField(output, 'title') ?? 'diagram'}.`, + output: `Uploaded ${text(output, 'title') ?? 'diagram'}.`, title: 'Created diagram', }; }, diff --git a/apps/bot/src/lib/ai/stream/tasks/pi.ts b/apps/bot/src/lib/ai/stream/tasks/pi.ts index 41b43aa0..2ca6e8e6 100644 --- a/apps/bot/src/lib/ai/stream/tasks/pi.ts +++ b/apps/bot/src/lib/ai/stream/tasks/pi.ts @@ -1,10 +1,10 @@ -import { textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { text } from './helpers'; -export const command: ToolTaskRendererEntry = { +export const command: TaskRendererEntry = { title: 'Running command', request: ({ input }) => { - const commandText = textField(input, 'command'); + const commandText = text(input, 'command'); if (!commandText) { return {}; } @@ -18,10 +18,10 @@ export const command: ToolTaskRendererEntry = { }, }; -export const file: ToolTaskRendererEntry = { +export const file: TaskRendererEntry = { title: 'Reading file', request: ({ input }) => { - const detail = textField(input, 'path') ?? textField(input, 'file_path'); + const detail = text(input, 'path') ?? text(input, 'file_path'); return { details: detail }; }, response: ({ toolName }) => { @@ -38,10 +38,10 @@ export const file: ToolTaskRendererEntry = { }, }; -export const search: ToolTaskRendererEntry = { +export const search: TaskRendererEntry = { title: 'Searching files', request: ({ input }) => { - const detail = textField(input, 'pattern') ?? textField(input, 'path'); + const detail = text(input, 'pattern') ?? text(input, 'path'); return { details: detail }; }, }; diff --git a/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts b/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts index b94f30ab..853c4a40 100644 --- a/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts +++ b/apps/bot/src/lib/ai/stream/tasks/schedule-reminder.ts @@ -1,17 +1,17 @@ -import { numberField, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { number, text } from './helpers'; -export const scheduleReminder: ToolTaskRendererEntry = { +export const scheduleReminder: TaskRendererEntry = { title: 'Scheduling reminder', request: ({ input }) => { - const seconds = numberField(input, 'seconds'); - const text = textField(input, 'text'); + const seconds = number(input, 'seconds'); + const reminder = text(input, 'text'); return { - details: `${seconds ?? '?'}s${text ? ` · ${text}` : ''}`, + details: `${seconds ?? '?'}s${reminder ? ` · ${reminder}` : ''}`, }; }, response: ({ output }) => { - const error = textField(output, 'error'); + const error = text(output, 'error'); if (error) { return { output: `Error: ${error}`, @@ -19,7 +19,7 @@ export const scheduleReminder: ToolTaskRendererEntry = { }; } return { - output: `Scheduled for ${textField(output, 'scheduledFor') ?? textField(output, 'userId') ?? 'later'}.`, + output: `Scheduled for ${text(output, 'scheduledFor') ?? text(output, 'userId') ?? 'later'}.`, title: 'Scheduled reminder', }; }, diff --git a/apps/bot/src/lib/ai/stream/tasks/search-slack.ts b/apps/bot/src/lib/ai/stream/tasks/search-slack.ts index b9e8f867..ad58343e 100644 --- a/apps/bot/src/lib/ai/stream/tasks/search-slack.ts +++ b/apps/bot/src/lib/ai/stream/tasks/search-slack.ts @@ -1,21 +1,21 @@ -import { numberField, plural, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { number, plural, text } from './helpers'; -export const searchSlack: ToolTaskRendererEntry = { +export const searchSlack: TaskRendererEntry = { title: 'Searching Slack', request: ({ input }) => ({ - details: textField(input, 'query'), + details: text(input, 'query'), }), response: ({ input, output }) => { - const error = textField(output, 'error'); + const error = text(output, 'error'); if (error) { return { output: `Error: ${error}`, title: 'Slack search failed', }; } - const count = numberField(output, 'resultCount') ?? 0; - const query = textField(input, 'query'); + const count = number(output, 'resultCount') ?? 0; + const query = text(input, 'query'); return { output: `Found ${plural(count, 'Slack result')}${query ? ` for "${query}"` : ''}.`, title: 'Searched Slack', diff --git a/apps/bot/src/lib/ai/stream/tasks/search-web.ts b/apps/bot/src/lib/ai/stream/tasks/search-web.ts index 3c3a0aea..1aec6c6d 100644 --- a/apps/bot/src/lib/ai/stream/tasks/search-web.ts +++ b/apps/bot/src/lib/ai/stream/tasks/search-web.ts @@ -1,20 +1,20 @@ -import { field, numberField, plural, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { number, plural, text, value } from './helpers'; -export const searchWeb: ToolTaskRendererEntry = { +export const searchWeb: TaskRendererEntry = { title: 'Searching the web', request: ({ input }) => ({ - details: textField(input, 'query'), + details: text(input, 'query'), }), response: ({ input, output }) => { - const count = numberField(output, 'resultCount') ?? 0; - const links = field(output, 'links'); + const count = number(output, 'resultCount') ?? 0; + const links = value(output, 'links'); const topLinks = Array.isArray(links) ? links .filter((link): link is string => typeof link === 'string') .slice(0, 3) : []; - const query = textField(input, 'query'); + const query = text(input, 'query'); return { output: `Found ${plural(count, 'web result')}${query ? ` for "${query}"` : ''}.${topLinks.length > 0 ? ` ${topLinks.join(', ')}` : ''}`, title: 'Searched the web', diff --git a/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts b/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts index a1f2014b..a2024f94 100644 --- a/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts +++ b/apps/bot/src/lib/ai/stream/tasks/summarize-thread.ts @@ -1,22 +1,21 @@ -import { numberField, plural, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { number, plural, text } from './helpers'; -export const summarizeThread: ToolTaskRendererEntry = { +export const summarizeThread: TaskRendererEntry = { title: 'Summarizing thread', request: ({ input }) => { - const detail = - textField(input, 'threadId') ?? textField(input, 'instructions'); + const detail = text(input, 'threadId') ?? text(input, 'instructions'); return { details: detail }; }, response: ({ output }) => { - const error = textField(output, 'error'); + const error = text(output, 'error'); if (error) { return { output: `Error: ${error}`, title: 'Summary failed', }; } - const count = numberField(output, 'messageCount'); + const count = number(output, 'messageCount'); return { output: count === undefined ? undefined : `Read ${plural(count, 'message')}.`, diff --git a/apps/bot/src/lib/ai/stream/tasks/types.ts b/apps/bot/src/lib/ai/stream/tasks/types.ts deleted file mode 100644 index e00aba38..00000000 --- a/apps/bot/src/lib/ai/stream/tasks/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -interface ToolTaskRenderInput { - input: unknown; - output?: unknown; - toolName: string; -} - -interface ToolTaskRenderResult { - details?: string; - output?: string; - title?: string; -} - -type ToolTaskRenderer = (input: ToolTaskRenderInput) => ToolTaskRenderResult; - -export interface DefaultToolTaskRenderer { - error: ToolTaskRenderer; - request: ToolTaskRenderer; - response: ToolTaskRenderer; -} - -export interface ToolTaskRendererEntry { - request?: ToolTaskRenderer; - response?: ToolTaskRenderer; - title: string; -} diff --git a/apps/bot/src/lib/ai/stream/tasks/upload-file.ts b/apps/bot/src/lib/ai/stream/tasks/upload-file.ts index 4cc3b808..7872bdf8 100644 --- a/apps/bot/src/lib/ai/stream/tasks/upload-file.ts +++ b/apps/bot/src/lib/ai/stream/tasks/upload-file.ts @@ -1,18 +1,18 @@ -import { booleanField, textField } from './helpers'; -import type { ToolTaskRendererEntry } from './types'; +import type { TaskRendererEntry } from '@/types/task-renderers'; +import { bool, text } from './helpers'; -export const uploadFile: ToolTaskRendererEntry = { +export const uploadFile: TaskRendererEntry = { title: 'Uploading file', request: ({ input }) => ({ - details: textField(input, 'path'), + details: text(input, 'path'), }), response: ({ input, output }) => { const filename = - textField(output, 'filename') ?? - textField(input, 'filename') ?? - textField(input, 'path') ?? + text(output, 'filename') ?? + text(input, 'filename') ?? + text(input, 'path') ?? 'file'; - const uploaded = booleanField(output, 'uploaded'); + const uploaded = bool(output, 'uploaded'); return { output: uploaded === false diff --git a/apps/bot/src/lib/ai/tools/generate-image.ts b/apps/bot/src/lib/ai/tools/generate-image.ts index 77eb8051..667ca931 100644 --- a/apps/bot/src/lib/ai/tools/generate-image.ts +++ b/apps/bot/src/lib/ai/tools/generate-image.ts @@ -1,13 +1,7 @@ import { provider } from '@repo/ai'; import { generateImage, tool } from 'ai'; import { z } from 'zod'; - -export interface GeneratedImage { - bytes: Uint8Array; - index: number; - mediaType: string; - total: number; -} +import type { GeneratedImage } from '@/types/tools/generate-image'; export function generateImageTool({ upload, diff --git a/apps/bot/src/lib/ai/tools/get-channel-info.ts b/apps/bot/src/lib/ai/tools/get-channel-info.ts index 55e50c04..8ccf369a 100644 --- a/apps/bot/src/lib/ai/tools/get-channel-info.ts +++ b/apps/bot/src/lib/ai/tools/get-channel-info.ts @@ -1,35 +1,24 @@ import { tool } from 'ai'; -import type { Chat } from 'chat'; import { z } from 'zod'; -import { slack } from '@/lib/chat'; import { toChatSlackChannelId } from '@/lib/slack/ids'; +import { assertReadableChannel } from './utils'; export function getChannelInfoTool({ - bot, currentThreadId, }: { - bot: Chat; currentThreadId: string; }) { return tool({ description: 'Fetch metadata for a channel: name, member count, DM status, visibility, etc.', inputSchema: z.object({ - channelId: z - .string() - .describe('Chat SDK channel id, e.g. slack:C123456.'), + channelId: z.string(), }), execute: async ({ channelId }) => { const chatChannelId = toChatSlackChannelId(channelId); - const info = await bot.channel(chatChannelId).fetchMetadata(); - if ( - slack.channelIdFromThreadId(currentThreadId) !== chatChannelId && - (info.isDM || info.channelVisibility !== 'workspace') - ) { - throw new Error( - 'Reading DMs, private channels, or external conversations is not allowed.' - ); - } + const info = await assertReadableChannel(chatChannelId, { + currentThreadId, + }); return { id: info.id, name: info.name, diff --git a/apps/bot/src/lib/ai/tools/get-user.ts b/apps/bot/src/lib/ai/tools/get-user.ts index 7b626597..a0795882 100644 --- a/apps/bot/src/lib/ai/tools/get-user.ts +++ b/apps/bot/src/lib/ai/tools/get-user.ts @@ -7,7 +7,7 @@ export function getUserTool() { description: "Look up a Slack user's profile by their user id (like U0123ABCD): display name, real name, pronouns, title, status, and custom profile fields (Website, GitHub, etc.). Use their pronouns when referring to them.", inputSchema: z.object({ - userId: z.string().min(1).describe('The Slack user id, like U0123ABCD.'), + userId: z.string().min(1), }), execute: async ({ userId }) => { const profile = await resolveUserProfile(userId); diff --git a/apps/bot/src/lib/ai/tools/list-threads.ts b/apps/bot/src/lib/ai/tools/list-threads.ts index 198d6fcd..cffc5e8a 100644 --- a/apps/bot/src/lib/ai/tools/list-threads.ts +++ b/apps/bot/src/lib/ai/tools/list-threads.ts @@ -13,20 +13,9 @@ export function listThreadsTool({ description: 'List recent Slack channel threads so you can pick a thread id before reading it. The current channel always works (even if private); other channels must be public.', inputSchema: z.object({ - channelId: z - .string() - .describe('Chat SDK channel id, e.g. slack:C123456.'), - cursor: z - .string() - .optional() - .describe('Slack pagination cursor from a previous response.'), - limit: z - .number() - .int() - .min(1) - .max(100) - .default(20) - .describe('Maximum thread roots to return.'), + channelId: z.string(), + cursor: z.string().optional(), + limit: z.number().int().min(1).max(100).default(20), }), execute: async ({ channelId, cursor, limit }) => { const chatChannelId = toChatSlackChannelId(channelId); diff --git a/apps/bot/src/lib/ai/tools/post-message.ts b/apps/bot/src/lib/ai/tools/post-message.ts new file mode 100644 index 00000000..09cb34a2 --- /dev/null +++ b/apps/bot/src/lib/ai/tools/post-message.ts @@ -0,0 +1,30 @@ +import { tool } from 'ai'; +import type { Chat } from 'chat'; +import { z } from 'zod'; + +export function postMessageTool({ bot }: { bot: Chat }) { + return tool({ + description: + 'Post a markdown-formatted message to another target. Type must be thread, channel, or user.', + inputSchema: z.object({ + type: z + .enum(['thread', 'channel', 'user']) + .describe('Target kind: thread, channel, or user.'), + id: z.string().min(1), + message: z.string().min(1).describe('Markdown message body.'), + }), + execute: async ({ id, message, type }) => { + if (type === 'thread') { + const sent = await bot.thread(id).post({ markdown: message }); + return { messageId: sent.id, threadId: sent.threadId }; + } + if (type === 'channel') { + const sent = await bot.channel(id).post({ markdown: message }); + return { messageId: sent.id, threadId: sent.threadId }; + } + const dm = await bot.openDM(id); + const sent = await dm.post({ markdown: message }); + return { messageId: sent.id, threadId: sent.threadId }; + }, + }); +} diff --git a/apps/bot/src/lib/ai/tools/posting.ts b/apps/bot/src/lib/ai/tools/posting.ts deleted file mode 100644 index 8069ca4c..00000000 --- a/apps/bot/src/lib/ai/tools/posting.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { type ToolSet, tool } from 'ai'; -import type { Chat } from 'chat'; -import { z } from 'zod'; - -const postableInputSchema = z.union([ - z.string().min(1).describe('Markdown body.'), - z.object({ markdown: z.string().min(1) }).describe('Markdown body.'), - z.object({ raw: z.string().min(1) }).describe('Raw body.'), -]); - -type MarkdownPostable = z.infer; - -function toMarkdownPostable( - message: MarkdownPostable -): { markdown: string } | { raw: string } { - if (typeof message === 'string') { - return { markdown: message }; - } - return message; -} - -export function postingTools({ bot }: { bot: Chat }): ToolSet { - return { - postMessage: tool({ - description: - 'Post a markdown-formatted reply inside an existing thread. Use a full Chat SDK thread id like slack:C123:1234567890.123456.', - inputSchema: z.object({ - threadId: z.string().min(1).describe('Full Chat SDK thread id.'), - message: postableInputSchema, - }), - execute: async ({ threadId, message }) => { - const sent = await bot - .thread(threadId) - .post(toMarkdownPostable(message)); - return { messageId: sent.id, threadId: sent.threadId }; - }, - }), - postChannelMessage: tool({ - description: - 'Post a markdown-formatted top-level message to a channel. Use a full Chat SDK channel id like slack:C123.', - inputSchema: z.object({ - channelId: z.string().min(1).describe('Full Chat SDK channel id.'), - message: postableInputSchema, - }), - execute: async ({ channelId, message }) => { - const sent = await bot - .channel(channelId) - .post(toMarkdownPostable(message)); - return { messageId: sent.id, threadId: sent.threadId }; - }, - }), - sendDirectMessage: tool({ - description: - 'Open or reuse a direct message with a user and post a markdown-formatted message.', - inputSchema: z.object({ - userId: z.string().min(1).describe('Platform-native user id.'), - message: postableInputSchema, - }), - execute: async ({ userId, message }) => { - const dm = await bot.openDM(userId); - const sent = await dm.post(toMarkdownPostable(message)); - return { messageId: sent.id, threadId: sent.threadId }; - }, - }), - }; -} diff --git a/apps/bot/src/lib/ai/tools/react.ts b/apps/bot/src/lib/ai/tools/react.ts new file mode 100644 index 00000000..1f77cbb9 --- /dev/null +++ b/apps/bot/src/lib/ai/tools/react.ts @@ -0,0 +1,19 @@ +import { tool } from 'ai'; +import type { Chat } from 'chat'; +import { z } from 'zod'; + +export function reactTool({ bot }: { bot: Chat }) { + return tool({ + description: 'Add an emoji reaction to a specific message.', + inputSchema: z.object({ + threadId: z.string(), + messageId: z.string(), + emoji: z.string(), + }), + execute: async ({ emoji, messageId, threadId }) => { + const thread = bot.thread(threadId); + await thread.adapter.addReaction(threadId, messageId, emoji); + return { added: true, emoji, messageId, threadId }; + }, + }); +} diff --git a/apps/bot/src/lib/ai/tools/read-conversation-history.ts b/apps/bot/src/lib/ai/tools/read-conversation-history.ts index c815bda1..0e92b8d1 100644 --- a/apps/bot/src/lib/ai/tools/read-conversation-history.ts +++ b/apps/bot/src/lib/ai/tools/read-conversation-history.ts @@ -11,29 +11,12 @@ export function readConversationHistoryTool({ }) { return tool({ description: - 'Read message history from a Slack channel or thread using a channel ID and optional thread timestamp. The current conversation is always readable (even a private channel or DM); other channels must be public.', + 'Read channel history or thread replies. The current conversation is always readable; other channels must be public.', inputSchema: z.object({ - channelId: z - .string() - .optional() - .describe('Chat SDK channel id, e.g. slack:C123456.'), - threadId: z - .string() - .optional() - .describe( - 'Optional full Chat SDK thread id, e.g. slack:C123456:1781599802.270109.' - ), - threadTs: z - .string() - .optional() - .describe('Optional Slack thread timestamp to read replies.'), - limit: z - .number() - .int() - .min(1) - .max(200) - .default(40) - .describe('Maximum messages to read.'), + channelId: z.string().optional(), + threadId: z.string().optional(), + threadTs: z.string().optional(), + limit: z.number().int().min(1).max(200).default(40), cursor: z .string() .optional() @@ -48,9 +31,7 @@ export function readConversationHistoryTool({ (threadId ? slack.channelIdFromThreadId(threadId) : undefined); const resolvedThreadTs = threadTs ?? decodedThread?.threadTs; if (!resolvedChannelId) { - throw new Error( - 'readConversationHistory needs channelId, or a full threadId like slack:C123456:1781599802.270109.' - ); + throw new Error('readConversationHistory needs channelId or threadId.'); } const chatChannelId = toChatSlackChannelId(resolvedChannelId); diff --git a/apps/bot/src/lib/ai/tools/schedule-reminder.ts b/apps/bot/src/lib/ai/tools/schedule-reminder.ts index 6e3db37e..e3349697 100644 --- a/apps/bot/src/lib/ai/tools/schedule-reminder.ts +++ b/apps/bot/src/lib/ai/tools/schedule-reminder.ts @@ -7,19 +7,14 @@ import { errorMessage } from '@/lib/utils/error'; export function scheduleReminderTool({ message }: { message: Message }) { return tool({ description: - 'Schedule a one-time reminder DM to the user who sent the current message. Use scheduleTask for recurring reminders once available.', + 'Schedule a one-time reminder DM to the user who sent the current message.', inputSchema: z.object({ - text: z - .string() - .min(1) - .max(3000) - .describe('Reminder message text to send to the user.'), + text: z.string().min(1).max(3000), seconds: z .number() .int() .min(1) - .max(120 * 24 * 60 * 60) - .describe('Seconds from now to send the reminder.'), + .max(120 * 24 * 60 * 60), }), execute: async ({ text, seconds }) => { const postAt = new Date(Date.now() + seconds * 1000); diff --git a/apps/bot/src/lib/ai/tools/search-slack.ts b/apps/bot/src/lib/ai/tools/search-slack.ts index cbc8fcb3..d0e40978 100644 --- a/apps/bot/src/lib/ai/tools/search-slack.ts +++ b/apps/bot/src/lib/ai/tools/search-slack.ts @@ -74,7 +74,7 @@ const slackSearchResponseSchema = z.looseObject({ .optional(), }); -export function searchSlack({ message }: { message: Message }) { +export function searchSlackTool({ message }: { message: Message }) { return tool({ description: 'Search Slack messages for past conversations, decisions, links, or context outside the current thread.', @@ -84,13 +84,7 @@ export function searchSlack({ message }: { message: Message }) { .min(1) .optional() .describe('Cursor from a previous Slack search result page.'), - query: z - .string() - .min(1) - .max(500) - .describe( - 'Specific Slack search query with keywords, people, channels, or dates.' - ), + query: z.string().min(1).max(500), }), execute: async ({ cursor, query }) => { const parsedRaw = actionTokenSchema.safeParse(message.raw); diff --git a/apps/bot/src/lib/ai/tools/search-web.ts b/apps/bot/src/lib/ai/tools/search-web.ts index 9e5bdedd..16997afd 100644 --- a/apps/bot/src/lib/ai/tools/search-web.ts +++ b/apps/bot/src/lib/ai/tools/search-web.ts @@ -2,7 +2,7 @@ import { tool } from 'ai'; import Exa from 'exa-js'; import { z } from 'zod'; -export function searchWeb({ apiKey }: { apiKey: string }) { +export function searchWebTool({ apiKey }: { apiKey: string }) { const exa = new Exa(apiKey); return tool({ description: @@ -15,10 +15,10 @@ export function searchWeb({ apiKey }: { apiKey: string }) { .describe("A specific, clear web search query for what you're after."), }), execute: async ({ query }) => { - const { results } = await exa.searchAndContents(query, { + const { results } = await exa.search(query, { type: 'auto', numResults: 8, - text: { maxCharacters: 1200 }, + contents: { text: { maxCharacters: 1200 } }, }); const links = results.slice(0, 5).map((result) => result.url); return { diff --git a/apps/bot/src/lib/ai/tools/summarize-thread.ts b/apps/bot/src/lib/ai/tools/summarize-thread.ts index e2dfd343..3906e6c2 100644 --- a/apps/bot/src/lib/ai/tools/summarize-thread.ts +++ b/apps/bot/src/lib/ai/tools/summarize-thread.ts @@ -19,20 +19,13 @@ export function summarizeThreadTool({ .string() .optional() .describe('Optional focus or format instructions for the summary.'), - threadId: z - .string() - .optional() - .describe( - 'Full Chat SDK thread id, e.g. slack:C123456:1781599802.270109. Defaults to the current thread.' - ), + threadId: z.string().optional(), }), execute: async (input) => { const targetThreadId = input.threadId ?? threadId; - if (targetThreadId.startsWith('slack:')) { - const channelId = slack.channelIdFromThreadId(targetThreadId); - await assertReadableChannel(channelId, { currentThreadId: threadId }); - await joinChannel(channelId); - } + const channelId = slack.channelIdFromThreadId(targetThreadId); + await assertReadableChannel(channelId, { currentThreadId: threadId }); + await joinChannel(channelId); const result = await bot .thread(targetThreadId) .adapter.fetchMessages(targetThreadId, { diff --git a/apps/bot/src/lib/ai/tools/utils.ts b/apps/bot/src/lib/ai/tools/utils.ts index f24ce647..f52b6e39 100644 --- a/apps/bot/src/lib/ai/tools/utils.ts +++ b/apps/bot/src/lib/ai/tools/utils.ts @@ -4,19 +4,20 @@ import { toRawSlackChannelId } from '@/lib/slack/ids'; export async function assertReadableChannel( chatChannelId: string, options?: { currentThreadId?: string } -): Promise { +) { const currentChannelId = options?.currentThreadId ? slack.channelIdFromThreadId(options.currentThreadId) : undefined; + const metadata = await bot.channel(chatChannelId).fetchMetadata(); if (currentChannelId && chatChannelId === currentChannelId) { - return; + return metadata; } - const metadata = await bot.channel(chatChannelId).fetchMetadata(); if (metadata.isDM || metadata.channelVisibility !== 'workspace') { throw new Error( 'Reading DMs, private channels, or external conversations is not allowed.' ); } + return metadata; } export async function joinChannel(channelId: string): Promise { diff --git a/apps/bot/src/lib/ai/toolset.ts b/apps/bot/src/lib/ai/toolset.ts index 3258a791..9499b221 100644 --- a/apps/bot/src/lib/ai/toolset.ts +++ b/apps/bot/src/lib/ai/toolset.ts @@ -2,7 +2,6 @@ import nodePath from 'node:path/posix'; import type { SandboxContext } from '@repo/ai'; import type { ToolSet } from 'ai'; import type { Chat, Message, Thread } from 'chat'; -import { createChatTools } from 'chat/ai'; import { env } from '@/env'; import { generateImageTool } from './tools/generate-image'; import { getChannelInfoTool } from './tools/get-channel-info'; @@ -11,11 +10,12 @@ import { getUserTool } from './tools/get-user'; import { leaveThreadTool } from './tools/leave-thread'; import { listThreadsTool } from './tools/list-threads'; import { mermaidTool } from './tools/mermaid'; -import { postingTools } from './tools/posting'; +import { postMessageTool } from './tools/post-message'; +import { reactTool } from './tools/react'; import { readConversationHistoryTool } from './tools/read-conversation-history'; import { scheduleReminderTool } from './tools/schedule-reminder'; -import { searchSlack } from './tools/search-slack'; -import { searchWeb } from './tools/search-web'; +import { searchSlackTool } from './tools/search-slack'; +import { searchWebTool } from './tools/search-web'; import { summarizeThreadTool } from './tools/summarize-thread'; import { uploadFileTool } from './tools/upload-file'; @@ -30,29 +30,21 @@ export function buildTools({ message: Message; thread: Thread; }): ToolSet { - const chatTools = createChatTools({ - chat: bot, - preset: 'messenger', - requireApproval: false, - }); - - const { addReaction } = chatTools; - return { - ...(addReaction && { addReaction }), + react: reactTool({ bot }), getUser: getUserTool(), - ...postingTools({ bot }), + postMessage: postMessageTool({ bot }), getFile: getFileTool({ getSandboxContext }), leaveThread: leaveThreadTool({ thread }), listThreads: listThreadsTool({ currentThreadId: thread.id }), readConversationHistory: readConversationHistoryTool({ currentThreadId: thread.id, }), - getChannelInfo: getChannelInfoTool({ bot, currentThreadId: thread.id }), + getChannelInfo: getChannelInfoTool({ currentThreadId: thread.id }), mermaid: mermaidTool({ thread }), scheduleReminder: scheduleReminderTool({ message }), - searchSlack: searchSlack({ message }), - searchWeb: searchWeb({ apiKey: env.EXA_API_KEY }), + searchSlack: searchSlackTool({ message }), + searchWeb: searchWebTool({ apiKey: env.EXA_API_KEY }), summarizeThread: summarizeThreadTool({ bot, threadId: thread.id }), generateImage: generateImageTool({ upload: async ({ bytes, mediaType, index, total }) => { diff --git a/apps/bot/src/lib/allowed-users.ts b/apps/bot/src/lib/allowed-users.ts index 4439b7eb..94247e0b 100644 --- a/apps/bot/src/lib/allowed-users.ts +++ b/apps/bot/src/lib/allowed-users.ts @@ -40,8 +40,12 @@ export async function addAllowedUser(userId: string): Promise { const allowedUsers = new Set( (await state.get(allowlistKey(channel))) ?? [] ); + const wasAllowed = allowedUsers.has(userId); allowedUsers.add(userId); await state.set(allowlistKey(channel), [...allowedUsers]); + if (!wasAllowed) { + logger.info({ channel, userId }, '[allowlist] user opted in'); + } } catch (error) { logger.warn( { ...toLogError(error), channel, userId }, diff --git a/apps/bot/src/lib/commands.ts b/apps/bot/src/lib/commands.ts new file mode 100644 index 00000000..6eb9d058 --- /dev/null +++ b/apps/bot/src/lib/commands.ts @@ -0,0 +1,75 @@ +import type { Message, Thread } from 'chat'; +import { compactTurn, stopTurn } from '@/lib/agent'; +import logger from '@/lib/logger'; +import { toLogError } from '@/lib/utils/error'; +import { rawText, withoutLeadingMentions } from '@/lib/utils/message'; + +type BotCommand = + | { + instructions?: string; + type: 'compact'; + } + | { + type: 'stop'; + }; + +export async function handleCommand({ + message, + thread, +}: { + message: Message; + thread: Thread; +}): Promise { + const command = cmd(message); + if (!command) { + return false; + } + if (command.type === 'compact') { + await compactTurn({ + instructions: command.instructions, + message, + thread, + }); + return true; + } + + const stopped = stopTurn({ threadId: thread.id }); + if (!stopped) { + await thread + .postEphemeral(message.author, 'no active response to stop.', { + fallbackToDM: false, + }) + .catch((error: unknown) => { + logger.warn( + { + ...toLogError(error), + threadId: thread.id, + userId: message.author.userId, + }, + 'Failed to post stop feedback' + ); + }); + } + return true; +} + +function cmd(message: Message): BotCommand | null { + const body = withoutLeadingMentions(rawText(message)).trim(); + + const match = body.match(/^!(\w+)\b(.*)$/is); + if (!match?.[1]) { + return null; + } + + switch (match[1].toLowerCase()) { + case 'compact': + return { + instructions: (match?.[2] ?? '').trim() || undefined, + type: 'compact', + }; + case 'stop': + return { type: 'stop' }; + default: + return null; + } +} diff --git a/apps/bot/src/lib/errors.ts b/apps/bot/src/lib/errors.ts index 18722496..ac01067b 100644 --- a/apps/bot/src/lib/errors.ts +++ b/apps/bot/src/lib/errors.ts @@ -1,4 +1,5 @@ import { errorMessage } from '@/lib/utils/error'; +import type { AgentErrorStage } from '@/types/agent'; const CREDIT_ERROR_PATTERN = /\b(credit|credits|quota|daily limit|requires more credits)\b/i; @@ -7,8 +8,6 @@ const CONTEXT_ERROR_PATTERN = const PROVIDER_TIMEOUT_PATTERN = /\b(5\d\d|gateway time-out|gateway timeout|cloudflare|timeout occurred|origin_response_timeout)\b/i; -export type AgentErrorStage = 'after_progress' | 'after_text' | 'before_output'; - export function agentErrorMessage({ error, stage = 'before_output', diff --git a/apps/bot/src/lib/onboarding.ts b/apps/bot/src/lib/onboarding.ts index 1f02a0e5..cd2d4811 100644 --- a/apps/bot/src/lib/onboarding.ts +++ b/apps/bot/src/lib/onboarding.ts @@ -7,6 +7,7 @@ import { CardText, type Thread, } from 'chat'; +import { z } from 'zod'; import { env } from '@/env'; import { addAllowedUser } from '@/lib/allowed-users'; import { slack } from '@/lib/chat'; @@ -18,7 +19,13 @@ import { toLogError } from '@/lib/utils/error'; // silence. Clicking "I accept" is the recorded consent: it grants access and // invites them into the terms channel. -export const OPT_IN_ACCEPT_ACTION = 'opt_in_accept'; +const slackErrorSchema = z.looseObject({ + data: z + .looseObject({ + error: z.string().optional(), + }) + .optional(), +}); export async function offerOptIn(thread: Thread, user: Author): Promise { if (!env.OPT_IN_CHANNEL) { @@ -28,18 +35,18 @@ export async function offerOptIn(thread: Thread, user: Author): Promise { await thread.postEphemeral( user, Card({ - title: '👋 First time meeting Gorkie', + title: ':wave: first time meeting gorkie', children: [ CardText( - `Hi! I'm Gorkie. Before I can help, you need to accept the terms posted in <#${env.OPT_IN_CHANNEL}>.` + `hi! i'm gorkie. before i can help, you need to accept the terms posted in <#${env.OPT_IN_CHANNEL}>.` ), CardText( - "Tap below to opt in — I'll add you to the terms channel and we can get started." + "tap below to opt in, i'll add you to the terms channel and we can get started." ), Actions([ Button({ - id: OPT_IN_ACCEPT_ACTION, - label: 'I accept — opt me in', + id: 'opt_in_accept', + label: 'i accept, opt me in', style: 'primary', value: thread.id, }), @@ -63,7 +70,7 @@ export async function acceptOptIn(event: ActionEvent): Promise { await event.thread ?.postEphemeral( event.user, - "✅ You're all set — welcome to Gorkie! Ask me anything.", + "you're all set, welcome to gorkie. ask me anything.", { fallbackToDM: true } ) .catch((error: unknown) => { @@ -83,7 +90,8 @@ async function inviteToOptInChannel(userId: string): Promise { await slack.webClient.conversations.invite({ channel, users: userId }); } catch (error) { // Already a member is success; external users can't be invited (we log it). - if (slackErrorCode(error) === 'already_in_channel') { + const slackError = slackErrorSchema.safeParse(error).data?.data?.error; + if (slackError === 'already_in_channel') { return; } logger.warn( @@ -92,14 +100,3 @@ async function inviteToOptInChannel(userId: string): Promise { ); } } - -function slackErrorCode(error: unknown): string | undefined { - if (error && typeof error === 'object' && 'data' in error) { - const data = (error as { data?: unknown }).data; - if (data && typeof data === 'object' && 'error' in data) { - const code = (data as { error?: unknown }).error; - return typeof code === 'string' ? code : undefined; - } - } - return; -} diff --git a/apps/bot/src/lib/slack/names.ts b/apps/bot/src/lib/slack/names.ts index 7587d2e4..020e578b 100644 --- a/apps/bot/src/lib/slack/names.ts +++ b/apps/bot/src/lib/slack/names.ts @@ -1,18 +1,14 @@ +import { z } from 'zod'; import { bot, slack } from '@/lib/chat'; +import type { UserProfile } from '@/types/names'; -interface ProfileField { - label: string; - value: string; -} - -export interface UserProfile { - displayName?: string; - fields?: ProfileField[]; - pronouns?: string; - realName?: string; - status?: string; - title?: string; -} +const profileFieldsSchema = z.record( + z.string(), + z.looseObject({ + label: z.string().optional(), + value: z.string().optional(), + }) +); export async function resolveUserProfile( userId: string @@ -33,10 +29,7 @@ export async function resolveUserProfile( if (!(raw || user)) { return; } - const fields = (raw?.fields ?? {}) as Record< - string, - { label?: string; value?: string } - >; + const fields = profileFieldsSchema.parse(raw?.fields ?? {}); profile = { displayName: raw?.display_name || undefined, fields: Object.values(fields).flatMap((field) => diff --git a/apps/bot/src/lib/utils/message.ts b/apps/bot/src/lib/utils/message.ts new file mode 100644 index 00000000..c72a1079 --- /dev/null +++ b/apps/bot/src/lib/utils/message.ts @@ -0,0 +1,24 @@ +import type { Message } from 'chat'; + +const leadingMentions = /^\s*(?:<@[A-Z0-9][A-Z0-9._-]*(?:\|[^>]+)?>\s*)+/; + +export function rawSlackText(message: Message): string | undefined { + const raw = message.raw; + if ( + !raw || + typeof raw !== 'object' || + !('text' in raw) || + typeof raw.text !== 'string' + ) { + return; + } + return raw.text; +} + +export function rawText(message: Message): string { + return rawSlackText(message) ?? message.text; +} + +export function withoutLeadingMentions(text: string): string { + return text.replace(leadingMentions, ''); +} diff --git a/apps/bot/src/types/agent.ts b/apps/bot/src/types/agent.ts new file mode 100644 index 00000000..019d742f --- /dev/null +++ b/apps/bot/src/types/agent.ts @@ -0,0 +1,15 @@ +import type { Message, Thread } from 'chat'; + +export type AgentErrorStage = 'after_progress' | 'after_text' | 'before_output'; + +export interface TurnInput { + message: Message; + thread: Thread; +} + +export type AbortReason = 'interrupt' | 'stop' | 'shutdown'; + +export interface ActiveTurn { + controller: AbortController; + pendingMessages: TurnInput[]; +} diff --git a/apps/bot/src/types/attachments.ts b/apps/bot/src/types/attachments.ts new file mode 100644 index 00000000..83f51e7b --- /dev/null +++ b/apps/bot/src/types/attachments.ts @@ -0,0 +1,6 @@ +export interface SeededAttachment { + mimeType?: string; + name: string; + path: string; + type: string; +} diff --git a/apps/bot/src/types/attempts.ts b/apps/bot/src/types/attempts.ts new file mode 100644 index 00000000..c8edea1c --- /dev/null +++ b/apps/bot/src/types/attempts.ts @@ -0,0 +1,6 @@ +import type { PiAttempt } from '@repo/ai'; + +export interface AttemptFailure { + attempt: PiAttempt; + error: unknown; +} diff --git a/apps/bot/src/types/names.ts b/apps/bot/src/types/names.ts new file mode 100644 index 00000000..8d38c8b7 --- /dev/null +++ b/apps/bot/src/types/names.ts @@ -0,0 +1,13 @@ +interface ProfileField { + label: string; + value: string; +} + +export interface UserProfile { + displayName?: string; + fields?: ProfileField[]; + pronouns?: string; + realName?: string; + status?: string; + title?: string; +} diff --git a/apps/bot/src/types/task-renderers.ts b/apps/bot/src/types/task-renderers.ts new file mode 100644 index 00000000..e0b3d5f1 --- /dev/null +++ b/apps/bot/src/types/task-renderers.ts @@ -0,0 +1,25 @@ +interface TaskRenderInput { + input: unknown; + output?: unknown; + toolName: string; +} + +interface RenderedTask { + details?: string; + output?: string; + title?: string; +} + +type TaskRenderer = (input: TaskRenderInput) => RenderedTask; + +export interface DefaultTaskRenderer { + error: TaskRenderer; + request: TaskRenderer; + response: TaskRenderer; +} + +export interface TaskRendererEntry { + request?: TaskRenderer; + response?: TaskRenderer; + title: string; +} diff --git a/apps/bot/src/types/tools/generate-image.ts b/apps/bot/src/types/tools/generate-image.ts new file mode 100644 index 00000000..4314392a --- /dev/null +++ b/apps/bot/src/types/tools/generate-image.ts @@ -0,0 +1,6 @@ +export interface GeneratedImage { + bytes: Uint8Array; + index: number; + mediaType: string; + total: number; +} diff --git a/apps/bot/src/features/customizations/types.ts b/apps/bot/src/types/views.ts similarity index 97% rename from apps/bot/src/features/customizations/types.ts rename to apps/bot/src/types/views.ts index 3af79e1d..98707949 100644 --- a/apps/bot/src/features/customizations/types.ts +++ b/apps/bot/src/types/views.ts @@ -16,7 +16,7 @@ interface SlackButtonElement { value?: string; } -export interface SlackTextInputElement { +interface SlackTextInputElement { action_id: string; initial_value?: string; max_length?: number; diff --git a/docs/architecture.md b/docs/architecture.md index 90f9c662..1e6142e4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,7 @@ sequenceDiagram | Chat SDK setup | `apps/bot/src/lib/chat.ts` | | Turn orchestration | `apps/bot/src/lib/agent/index.ts` | | Turn interruption and stop controls | `apps/bot/src/lib/agent/steering.ts`, `apps/bot/src/lib/agent/controls.ts` | -| Slack reply chunking | `apps/bot/src/lib/agent/line-reply.ts` | +| Slack reply chunking | `apps/bot/src/lib/agent/reply.ts` | | Stream and task rendering | `apps/bot/src/lib/ai/stream/**` | | Host tools | `apps/bot/src/lib/ai/tools/**`, `apps/bot/src/lib/ai/toolset.ts` | | Agent construction | `packages/ai/src/agent.ts` | diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 21391d58..322922b4 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -23,14 +23,12 @@ These tools operate in the sandbox. They do not call Slack directly. ## Slack And Chat Tools -`apps/bot/src/lib/ai/toolset.ts` starts from Chat SDK's `messenger` preset and exposes only the tools Gorkie currently wants: +`apps/bot/src/lib/ai/toolset.ts` exposes bot-owned tools for Slack actions: | Tool | Purpose | | --- | --- | -| `postMessage` | Post to another thread. | -| `postChannelMessage` | Post to a channel. | -| `sendDirectMessage` | Send a DM. | -| `addReaction` | React to a message. | +| `postMessage` | Post to another thread, channel, or user. | +| `react` | React to a message. | | `getChannelInfo` | Read channel metadata. | | `getUser` | Read user metadata. | diff --git a/docs/runtime/streaming.md b/docs/runtime/streaming.md index 55ed9ced..e7c8901f 100644 --- a/docs/runtime/streaming.md +++ b/docs/runtime/streaming.md @@ -5,14 +5,14 @@ description: Assistant text, task rows, and Slack limits. Gorkie renders a turn through two Slack output paths: -- assistant text is posted as normal Slack replies through `createLineReply`; +- assistant text is posted as normal Slack replies through `createReply`; - reasoning and tool activity are rendered as task rows through Chat SDK `StreamingPlan`. > **Slack message limits:** Long native Slack stream buffers can fail with `msg_too_long`. Gorkie keeps assistant text outside the native stream buffer so long answers can be split into multiple Slack messages. ## Text Replies -`apps/bot/src/lib/agent/line-reply.ts` buffers text deltas and posts chunks at natural boundaries. It prefers paragraph breaks, then sentence or line boundaries, and falls back to a hard size split before Slack's message limit. +`apps/bot/src/lib/agent/reply.ts` buffers text deltas and posts chunks at natural boundaries. It prefers paragraph breaks, then sentence or line boundaries, and falls back to a hard size split before Slack's message limit. The splitting logic also avoids splitting inside open fenced code blocks. diff --git a/packages/ai/src/prompts/core.ts b/packages/ai/src/prompts/core.ts index 06cf9400..81abc662 100644 --- a/packages/ai/src/prompts/core.ts +++ b/packages/ai/src/prompts/core.ts @@ -4,6 +4,11 @@ You're Gorkie. Your default identity and style are only the fallback when the user has not set persistent custom instructions. If the user has set instructions for tone, persona, style, language, formatting, or how to address them, those override the default Gorkie presentation unless they conflict with safety rules or hard system constraints. Never tell the user you cannot follow their saved custom instructions for "developer", "system", "persona", or "priority" reasons unless there is a real safety conflict. Do not lecture about instruction hierarchy. If you failed to follow them, briefly acknowledge it and correct course. +Current speaker instructions: +- An incoming message may include a block before the message text. This is the current speaker's saved customization for this turn. +- Follow the current speaker's customization unless it conflicts with safety requirements or hard system constraints. +- Treat earlier blocks from other speakers as historical context only. + Limitations: - You CANNOT log in to websites, authenticate, or reach anything behind auth (private repos, Google Docs, Jira, private APIs). - You have no direct web browser, but you can fetch and process PUBLIC URLs by running code in your sandbox. diff --git a/packages/ai/src/prompts/customization.ts b/packages/ai/src/prompts/customization.ts deleted file mode 100644 index a92aa2f1..00000000 --- a/packages/ai/src/prompts/customization.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { RequestHints } from './hints'; - -export function customizationPrompt(hints: RequestHints): string | null { - const prompt = hints.customization?.prompt; - if (!prompt) { - return null; - } - return `\ - -The user you're talking to has set the following persistent personal instructions. They are mandatory and must be followed exactly unless they conflict with safety requirements or higher-priority system rules. Treat them as an active behavioral contract, not a suggestion: obey any tone, language, brevity, formatting, or addressing instructions strictly. -${prompt} -`; -} diff --git a/packages/ai/src/prompts/index.ts b/packages/ai/src/prompts/index.ts index 4b12ddc8..4a3e4e64 100644 --- a/packages/ai/src/prompts/index.ts +++ b/packages/ai/src/prompts/index.ts @@ -1,6 +1,5 @@ import { contextPrompt } from './context'; import { corePrompt } from './core'; -import { customizationPrompt } from './customization'; import type { RequestHints } from './hints'; import { personalityPrompt } from './personality'; import { sandboxPrompt } from './sandbox'; @@ -15,7 +14,6 @@ export function systemPrompt({ hints }: { hints: RequestHints }): string { sandboxPrompt, slackPrompt, contextPrompt(hints), - customizationPrompt(hints), ] .filter(Boolean) .join('\n\n') diff --git a/packages/ai/src/prompts/slack.ts b/packages/ai/src/prompts/slack.ts index af1fd06d..b01acb48 100644 --- a/packages/ai/src/prompts/slack.ts +++ b/packages/ai/src/prompts/slack.ts @@ -28,8 +28,8 @@ Read: - getFile: download a Slack file (upload, snippet, image, canvas, any type) into the sandbox by URL, permalink, or file id so you can read it. Act: -- addReaction: react to a message with an emoji. -- postMessage / postChannelMessage / sendDirectMessage: send a message to ANOTHER thread, channel, or user. Your streamed text is the reply to the current message; never post your reply through a tool. +- react: react to a message with an emoji. +- postMessage: send a message to ANOTHER thread, channel, or user. Your streamed text is the reply to the current message; never post your reply through a tool. - searchWeb: search the internet for current info, docs, or facts. don't guess at recent events, search. - generateImage: generate AI image(s) from a prompt and post them to the thread; use it for image creation requests. - mermaid: render a Mermaid diagram and upload it to this Slack thread. diff --git a/packages/ai/src/providers/models.ts b/packages/ai/src/providers/models.ts index e21207ba..d7ba43d5 100644 --- a/packages/ai/src/providers/models.ts +++ b/packages/ai/src/providers/models.ts @@ -14,8 +14,6 @@ export const provider: Provider = customProvider({ 'chat-model': hostProvider.languageModel('google/gemini-3-flash-preview'), }, imageModels: { - 'image-model': hostProvider.imageModel( - 'google/gemini-3.1-flash-image-preview' - ), + 'image-model': hostProvider.imageModel('google/gemini-3.1-flash-image'), }, }); diff --git a/packages/sandbox/src/provider.ts b/packages/sandbox/src/provider.ts index 48f235be..c9eb44b2 100644 --- a/packages/sandbox/src/provider.ts +++ b/packages/sandbox/src/provider.ts @@ -136,7 +136,7 @@ export class E2BSandboxProvider implements HarnessV1SandboxProvider { this.logger.info( { sessionId, sandboxId: nextSandbox.sandboxId, template: this.template }, - '[sandbox] created e2b sandbox' + '[sandbox] created sandbox' ); return session; diff --git a/REWRITE_PLAN.md b/plans/rewrite.md similarity index 99% rename from REWRITE_PLAN.md rename to plans/rewrite.md index acd5f7b0..ea66f578 100644 --- a/REWRITE_PLAN.md +++ b/plans/rewrite.md @@ -123,8 +123,9 @@ typecheck/build yet — that's expected. All removed code (incl. old schemas) li | `packages/logging` | Keep — pino. | **AI deps:** the old `ai-retry` dependency/patch is removed, but the behavior is still needed. -Rebuild an ai-powered retry/fallback layer for Harness/pi model calls instead of trying to wrap -AI SDK `generateText` directly. Keep app-specific host-tool deps in `apps/bot`, not `packages/ai`. +Rebuild retry/fallback around Harness/Pi model attempts instead of assuming every model call is +a direct AI SDK `generateText` call. Keep app-specific host-tool deps in `apps/bot`, not +`packages/ai`. `apps/server` (MCP OAuth callback) returns only in the MCP phase. ## 4. Target architecture