Unified · Split
| @@ -1 +0,0 @@ | ||
| 1 | −Subproject commit 6f4665dda3af787c50685e440cca21a57816b5a6 | |
| @@ -1 +0,0 @@ | ||
| 1 | −Subproject commit 43c45b2cbe0e64fb7859164a28e29da427c37930 | |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | ||
| 16 | 16 | "@effect/platform": "latest", |
| 17 | 17 | "@effect/rpc": "latest", |
| 18 | 18 | "@openassistant/core": "workspace:*", |
| 19 | + "@openassistant/gateway": "workspace:*", | |
| 19 | 20 | "@openassistant/reacord": "workspace:*", |
| 20 | 21 | "ai": "^6.0.73", |
| 21 | 22 | "discord.js": "^14.17.3", |
| @@ -0,0 +1,239 @@ | ||
| 1 | +import type { TurnResult } from "@openassistant/gateway/rpc"; | |
| 2 | +import type { ButtonInteraction } from "discord.js"; | |
| 3 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 4 | +import { ApprovalRequestView, AssistantReplyView, AssistantWorkingView } from "./discord-views.js"; | |
| 5 | +import { formatDiscordResponse } from "./format-response.js"; | |
| 6 | +import { continueGatewayTurn, resolveGatewayApproval, runGatewayTurn } from "./gateway-client.js"; | |
| 7 | + | |
| 8 | +type ApprovalState = NonNullable<TurnResult["approval"]>; | |
| 9 | + | |
| 10 | +type SessionState = | |
| 11 | + | { phase: "starting" } | |
| 12 | + | { | |
| 13 | + phase: "awaiting"; | |
| 14 | + turnId: string; | |
| 15 | + approval: ApprovalState; | |
| 16 | + resolving: boolean; | |
| 17 | + resolved?: { decision: "approved" | "denied"; actorId?: string }; | |
| 18 | + } | |
| 19 | + | { phase: "completed"; message: string; footer?: string } | |
| 20 | + | { phase: "failed"; message: string }; | |
| 21 | + | |
| 22 | +export function AgentSessionView(params: { | |
| 23 | + prompt: string; | |
| 24 | + requesterId: string; | |
| 25 | + channelId: string; | |
| 26 | + approvalTimeoutMs: number; | |
| 27 | +}) { | |
| 28 | + const { prompt, requesterId, channelId, approvalTimeoutMs } = params; | |
| 29 | + const [state, setState] = useState<SessionState>({ phase: "starting" }); | |
| 30 | + const isMounted = useRef(true); | |
| 31 | + | |
| 32 | + useEffect(() => { | |
| 33 | + return () => { | |
| 34 | + isMounted.current = false; | |
| 35 | + }; | |
| 36 | + }, []); | |
| 37 | + | |
| 38 | + const applyTurnResult = useCallback((turn: TurnResult) => { | |
| 39 | + if (!isMounted.current) { | |
| 40 | + return; | |
| 41 | + } | |
| 42 | + if (turn.status === "awaiting_approval") { | |
| 43 | + if (!turn.approval) { | |
| 44 | + setState({ | |
| 45 | + phase: "failed", | |
| 46 | + message: "Approval was required but no approval payload was returned.", | |
| 47 | + }); | |
| 48 | + return; | |
| 49 | + } | |
| 50 | + setState({ | |
| 51 | + phase: "awaiting", | |
| 52 | + turnId: turn.turnId, | |
| 53 | + approval: turn.approval, | |
| 54 | + resolving: false, | |
| 55 | + }); | |
| 56 | + return; | |
| 57 | + } | |
| 58 | + | |
| 59 | + if (turn.status === "failed") { | |
| 60 | + setState({ | |
| 61 | + phase: "failed", | |
| 62 | + message: turn.error ? `I couldn't complete that request: ${turn.error}` : "I couldn't complete that request.", | |
| 63 | + }); | |
| 64 | + return; | |
| 65 | + } | |
| 66 | + | |
| 67 | + const response = formatDiscordResponse({ | |
| 68 | + text: turn.message ?? "", | |
| 69 | + footer: turn.footer, | |
| 70 | + }); | |
| 71 | + setState({ | |
| 72 | + phase: "completed", | |
| 73 | + message: response.message, | |
| 74 | + ...(response.footer ? { footer: response.footer } : {}), | |
| 75 | + }); | |
| 76 | + }, []); | |
| 77 | + | |
| 78 | + const continueAfterDecision = useCallback( | |
| 79 | + async (params: { | |
| 80 | + turnId: string; | |
| 81 | + callId: string; | |
| 82 | + decision: "approved" | "denied"; | |
| 83 | + actorId: string; | |
| 84 | + }) => { | |
| 85 | + const { turnId, callId, decision, actorId } = params; | |
| 86 | + setState((current) => { | |
| 87 | + if ( | |
| 88 | + current.phase !== "awaiting" || | |
| 89 | + current.turnId !== turnId || | |
| 90 | + current.approval.callId !== callId | |
| 91 | + ) { | |
| 92 | + return current; | |
| 93 | + } | |
| 94 | + return { | |
| 95 | + ...current, | |
| 96 | + resolving: true, | |
| 97 | + resolved: { decision, actorId }, | |
| 98 | + }; | |
| 99 | + }); | |
| 100 | + | |
| 101 | + try { | |
| 102 | + const resolved = await resolveGatewayApproval({ | |
| 103 | + turnId, | |
| 104 | + callId, | |
| 105 | + actorId, | |
| 106 | + decision, | |
| 107 | + }); | |
| 108 | + if (resolved.status !== "resolved") { | |
| 109 | + if (!isMounted.current) { | |
| 110 | + return; | |
| 111 | + } | |
| 112 | + setState({ | |
| 113 | + phase: "failed", | |
| 114 | + message: `Approval could not be applied: ${resolved.status}.`, | |
| 115 | + }); | |
| 116 | + return; | |
| 117 | + } | |
| 118 | + const next = await continueGatewayTurn(turnId); | |
| 119 | + applyTurnResult(next); | |
| 120 | + } catch (error) { | |
| 121 | + if (!isMounted.current) { | |
| 122 | + return; | |
| 123 | + } | |
| 124 | + setState({ | |
| 125 | + phase: "failed", | |
| 126 | + message: `I hit an unexpected error while continuing this request: ${describeUnknown(error)}`, | |
| 127 | + }); | |
| 128 | + } | |
| 129 | + }, | |
| 130 | + [applyTurnResult], | |
| 131 | + ); | |
| 132 | + | |
| 133 | + useEffect(() => { | |
| 134 | + void (async () => { | |
| 135 | + try { | |
| 136 | + const turn = await runGatewayTurn({ | |
| 137 | + prompt, | |
| 138 | + requesterId, | |
| 139 | + channelId, | |
| 140 | + }); | |
| 141 | + applyTurnResult(turn); | |
| 142 | + } catch (error) { | |
| 143 | + if (!isMounted.current) { | |
| 144 | + return; | |
| 145 | + } | |
| 146 | + setState({ | |
| 147 | + phase: "failed", | |
| 148 | + message: `I hit an unexpected error while processing that request: ${describeUnknown(error)}`, | |
| 149 | + }); | |
| 150 | + } | |
| 151 | + })(); | |
| 152 | + }, [prompt, requesterId, channelId, applyTurnResult]); | |
| 153 | + | |
| 154 | + useEffect(() => { | |
| 155 | + if (state.phase !== "awaiting" || state.resolving) { | |
| 156 | + return; | |
| 157 | + } | |
| 158 | + const timer = setTimeout(() => { | |
| 159 | + void continueAfterDecision({ | |
| 160 | + turnId: state.turnId, | |
| 161 | + callId: state.approval.callId, | |
| 162 | + decision: "denied", | |
| 163 | + actorId: requesterId, | |
| 164 | + }); | |
| 165 | + }, approvalTimeoutMs); | |
| 166 | + | |
| 167 | + return () => clearTimeout(timer); | |
| 168 | + }, [approvalTimeoutMs, continueAfterDecision, requesterId, state]); | |
| 169 | + | |
| 170 | + if (state.phase === "starting") { | |
| 171 | + return <AssistantWorkingView />; | |
| 172 | + } | |
| 173 | + | |
| 174 | + if (state.phase === "failed") { | |
| 175 | + return <AssistantReplyView message={state.message} />; | |
| 176 | + } | |
| 177 | + | |
| 178 | + if (state.phase === "completed") { | |
| 179 | + return <AssistantReplyView message={state.message} footer={state.footer} />; | |
| 180 | + } | |
| 181 | + | |
| 182 | + return ( | |
| 183 | + <ApprovalRequestView | |
| 184 | + toolPath={state.approval.toolPath} | |
| 185 | + callId={state.approval.callId} | |
| 186 | + inputPreview={state.approval.inputPreview} | |
| 187 | + requesterId={requesterId} | |
| 188 | + resolved={state.resolved} | |
| 189 | + onApprove={async (interaction) => { | |
| 190 | + await handleDecisionInteraction({ | |
| 191 | + interaction, | |
| 192 | + requesterId, | |
| 193 | + onAllowed: () => | |
| 194 | + continueAfterDecision({ | |
| 195 | + turnId: state.turnId, | |
| 196 | + callId: state.approval.callId, | |
| 197 | + decision: "approved", | |
| 198 | + actorId: interaction.user.id, | |
| 199 | + }), | |
| 200 | + }); | |
| 201 | + }} | |
| 202 | + onDeny={async (interaction) => { | |
| 203 | + await handleDecisionInteraction({ | |
| 204 | + interaction, | |
| 205 | + requesterId, | |
| 206 | + onAllowed: () => | |
| 207 | + continueAfterDecision({ | |
| 208 | + turnId: state.turnId, | |
| 209 | + callId: state.approval.callId, | |
| 210 | + decision: "denied", | |
| 211 | + actorId: interaction.user.id, | |
| 212 | + }), | |
| 213 | + }); | |
| 214 | + }} | |
| 215 | + /> | |
| 216 | + ); | |
| 217 | +} | |
| 218 | + | |
| 219 | +async function handleDecisionInteraction(params: { | |
| 220 | + interaction: ButtonInteraction; | |
| 221 | + requesterId: string; | |
| 222 | + onAllowed: () => Promise<void>; | |
| 223 | +}): Promise<void> { | |
| 224 | + if (params.interaction.user.id !== params.requesterId) { | |
| 225 | + await params.interaction.reply({ | |
| 226 | + content: "Only the requesting user can resolve this approval.", | |
| 227 | + ephemeral: true, | |
| 228 | + }); | |
| 229 | + return; | |
| 230 | + } | |
| 231 | + await params.onAllowed(); | |
| 232 | +} | |
| 233 | + | |
| 234 | +function describeUnknown(error: unknown): string { | |
| 235 | + if (error instanceof Error) { | |
| 236 | + return error.message; | |
| 237 | + } | |
| 238 | + return String(error); | |
| 239 | +} | |
| @@ -1,100 +0,0 @@ | ||
| 1 | −import type { ApprovalDecision, ApprovalRequest } from "@openassistant/core"; | |
| 2 | −import { type ReacordInstance, makeReacord } from "@openassistant/reacord"; | |
| 3 | −import type { ButtonInteraction, Client, SendableChannels } from "discord.js"; | |
| 4 | −import { Effect } from "effect"; | |
| 5 | −import { ApprovalRegistry } from "./approval-registry.js"; | |
| 6 | −import { ApprovalRequestView } from "./discord-views.js"; | |
| 7 | − | |
| 8 | −type ReacordApi = ReturnType<typeof makeReacord>; | |
| 9 | − | |
| 10 | −export class DiscordApprovalBridge { | |
| 11 | − private readonly registry: ApprovalRegistry; | |
| 12 | − private readonly reacord: ReacordApi; | |
| 13 | − | |
| 14 | − constructor(client: Client, timeoutMs: number = 5 * 60_000) { | |
| 15 | − this.registry = new ApprovalRegistry(timeoutMs); | |
| 16 | − this.reacord = makeReacord(client); | |
| 17 | − } | |
| 18 | − | |
| 19 | − async requestApproval(params: { | |
| 20 | − request: ApprovalRequest; | |
| 21 | − channel: SendableChannels; | |
| 22 | − requesterId: string; | |
| 23 | − }): Promise<ApprovalDecision> { | |
| 24 | − const { request, channel, requesterId } = params; | |
| 25 | − const pending = this.registry.open(request.callId, requesterId); | |
| 26 | − let instance: ReacordInstance | null = null; | |
| 27 | − let resolvedActorId: string | undefined; | |
| 28 | − | |
| 29 | − try { | |
| 30 | − const handleDecision = async (interaction: ButtonInteraction, decision: ApprovalDecision): Promise<void> => { | |
| 31 | − const status = this.registry.resolve(request.callId, interaction.user.id, decision); | |
| 32 | − if (status === "unauthorized") { | |
| 33 | − await interaction.reply({ | |
| 34 | − content: "Only the requesting user can resolve this approval.", | |
| 35 | − ephemeral: true, | |
| 36 | − }); | |
| 37 | − return; | |
| 38 | − } | |
| 39 | − | |
| 40 | − if (status === "not_found") { | |
| 41 | − await interaction.reply({ | |
| 42 | − content: "This approval is no longer pending.", | |
| 43 | − ephemeral: true, | |
| 44 | − }); | |
| 45 | − return; | |
| 46 | − } | |
| 47 | − | |
| 48 | − resolvedActorId = interaction.user.id; | |
| 49 | − instance?.render( | |
| 50 | − <ApprovalRequestView | |
| 51 | − toolPath={request.toolPath} | |
| 52 | − callId={request.callId} | |
| 53 | − inputPreview={request.inputPreview} | |
| 54 | − requesterId={requesterId} | |
| 55 | − resolved={{ decision, actorId: interaction.user.id }} | |
| 56 | − onApprove={handleApprove} | |
| 57 | − onDeny={handleDeny} | |
| 58 | − />, | |
| 59 | − ); | |
| 60 | − }; | |
| 61 | − const handleApprove = (interaction: ButtonInteraction) => handleDecision(interaction, "approved"); | |
| 62 | − const handleDeny = (interaction: ButtonInteraction) => handleDecision(interaction, "denied"); | |
| 63 | − | |
| 64 | − instance = await Effect.runPromise( | |
| 65 | − this.reacord.send( | |
| 66 | − channel, | |
| 67 | − <ApprovalRequestView | |
| 68 | − toolPath={request.toolPath} | |
| 69 | − callId={request.callId} | |
| 70 | − inputPreview={request.inputPreview} | |
| 71 | − requesterId={requesterId} | |
| 72 | − onApprove={handleApprove} | |
| 73 | − onDeny={handleDeny} | |
| 74 | − />, | |
| 75 | − ), | |
| 76 | − ); | |
| 77 | − } catch { | |
| 78 | − this.registry.cancel(request.callId, "denied"); | |
| 79 | − } | |
| 80 | − | |
| 81 | − const decision = await pending; | |
| 82 | − | |
| 83 | − if (instance) { | |
| 84 | − instance.render( | |
| 85 | − <ApprovalRequestView | |
| 86 | − toolPath={request.toolPath} | |
| 87 | − callId={request.callId} | |
| 88 | − inputPreview={request.inputPreview} | |
| 89 | − requesterId={requesterId} | |
| 90 | − resolved={{ decision, actorId: resolvedActorId }} | |
| 91 | − onApprove={() => {}} | |
| 92 | − onDeny={() => {}} | |
| 93 | − />, | |
| 94 | − ); | |
| 95 | − instance.deactivate(); | |
| 96 | − } | |
| 97 | − | |
| 98 | − return decision; | |
| 99 | − } | |
| 100 | −} | |
| @@ -1,38 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "vitest"; | |
| 2 | −import { ApprovalRegistry } from "./approval-registry.js"; | |
| 3 | − | |
| 4 | −describe("ApprovalRegistry", () => { | |
| 5 | − it("resolves for requester", async () => { | |
| 6 | − const registry = new ApprovalRegistry(1_000); | |
| 7 | − const pending = registry.open("call_1", "user_a"); | |
| 8 | − | |
| 9 | − const result = registry.resolve("call_1", "user_a", "approved"); | |
| 10 | − const decision = await pending; | |
| 11 | − | |
| 12 | − expect(result).toBe("resolved"); | |
| 13 | − expect(decision).toBe("approved"); | |
| 14 | − expect(registry.size()).toBe(0); | |
| 15 | − }); | |
| 16 | − | |
| 17 | − it("rejects unauthorized actor", async () => { | |
| 18 | − const registry = new ApprovalRegistry(1_000); | |
| 19 | − const pending = registry.open("call_2", "user_a"); | |
| 20 | − | |
| 21 | − const unauthorized = registry.resolve("call_2", "user_b", "approved"); | |
| 22 | − expect(unauthorized).toBe("unauthorized"); | |
| 23 | − expect(registry.size()).toBe(1); | |
| 24 | − | |
| 25 | − registry.cancel("call_2"); | |
| 26 | − const decision = await pending; | |
| 27 | − expect(decision).toBe("denied"); | |
| 28 | − }); | |
| 29 | − | |
| 30 | − it("times out to denied", async () => { | |
| 31 | − const registry = new ApprovalRegistry(10); | |
| 32 | − const pending = registry.open("call_3", "user_a"); | |
| 33 | − | |
| 34 | − const decision = await pending; | |
| 35 | − expect(decision).toBe("denied"); | |
| 36 | − expect(registry.size()).toBe(0); | |
| 37 | − }); | |
| 38 | −}); | |
| @@ -1,67 +0,0 @@ | ||
| 1 | −import type { ApprovalDecision } from "@openassistant/core"; | |
| 2 | − | |
| 3 | −type PendingApproval = { | |
| 4 | − requesterId: string; | |
| 5 | − resolve: (decision: ApprovalDecision) => void; | |
| 6 | − timeout: ReturnType<typeof setTimeout>; | |
| 7 | −}; | |
| 8 | − | |
| 9 | −export type ResolveApprovalResult = "resolved" | "not_found" | "unauthorized"; | |
| 10 | − | |
| 11 | −export class ApprovalRegistry { | |
| 12 | − private readonly pending = new Map<string, PendingApproval>(); | |
| 13 | − | |
| 14 | − constructor(private readonly timeoutMs: number = 5 * 60_000) {} | |
| 15 | − | |
| 16 | − open(callId: string, requesterId: string): Promise<ApprovalDecision> { | |
| 17 | − if (this.pending.has(callId)) { | |
| 18 | − throw new Error(`Approval already pending: ${callId}`); | |
| 19 | − } | |
| 20 | − | |
| 21 | − return new Promise<ApprovalDecision>((resolve) => { | |
| 22 | − const timeout = setTimeout(() => { | |
| 23 | − this.finish(callId, "denied"); | |
| 24 | − }, this.timeoutMs); | |
| 25 | − | |
| 26 | − this.pending.set(callId, { | |
| 27 | − requesterId, | |
| 28 | − resolve, | |
| 29 | − timeout, | |
| 30 | − }); | |
| 31 | − }); | |
| 32 | − } | |
| 33 | − | |
| 34 | − resolve(callId: string, actorId: string, decision: ApprovalDecision): ResolveApprovalResult { | |
| 35 | − const entry = this.pending.get(callId); | |
| 36 | − if (!entry) { | |
| 37 | − return "not_found"; | |
| 38 | − } | |
| 39 | − if (entry.requesterId !== actorId) { | |
| 40 | − return "unauthorized"; | |
| 41 | − } | |
| 42 | − | |
| 43 | − this.finish(callId, decision); | |
| 44 | − return "resolved"; | |
| 45 | − } | |
| 46 | − | |
| 47 | − cancel(callId: string, decision: ApprovalDecision = "denied"): void { | |
| 48 | − if (!this.pending.has(callId)) { | |
| 49 | − return; | |
| 50 | − } | |
| 51 | − this.finish(callId, decision); | |
| 52 | − } | |
| 53 | − | |
| 54 | − size(): number { | |
| 55 | − return this.pending.size; | |
| 56 | − } | |
| 57 | − | |
| 58 | − private finish(callId: string, decision: ApprovalDecision): void { | |
| 59 | − const entry = this.pending.get(callId); | |
| 60 | − if (!entry) { | |
| 61 | − return; | |
| 62 | − } | |
| 63 | − this.pending.delete(callId); | |
| 64 | − clearTimeout(entry.timeout); | |
| 65 | − entry.resolve(decision); | |
| 66 | − } | |
| 67 | −} | |
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | import { FetchHttpClient } from "@effect/platform"; |
| 2 | 2 | import { RpcClient, RpcSerialization } from "@effect/rpc"; |
| 3 | −import { AgentRpcs, type RunTurnOutput } from "@openassistant/core"; | |
| 3 | +import { | |
| 4 | + AgentRpcs, | |
| 5 | + type ResolveApprovalOutput, | |
| 6 | + type TurnResult, | |
| 7 | +} from "@openassistant/gateway/rpc"; | |
| 4 | 8 | import { Effect, Layer } from "effect"; |
| 5 | 9 | |
| 6 | 10 | const gatewayUrl = readEnv("OPENASSISTANT_GATEWAY_URL") ?? "http://127.0.0.1:8787/rpc"; |
| @@ -15,7 +19,7 @@ export interface RunGatewayTurnInput { · 8 unmodified lines | ||
| 15 | 19 | channelId: string; |
| 16 | 20 | } |
| 17 | 21 | |
| 18 | −export async function runGatewayTurn(input: RunGatewayTurnInput): Promise<RunTurnOutput> { | |
| 22 | +export async function runGatewayTurn(input: RunGatewayTurnInput): Promise<TurnResult> { | |
| 19 | 23 | return Effect.runPromise( |
| 20 | 24 | Effect.gen(function* () { |
| 21 | 25 | const client = yield* RpcClient.make(AgentRpcs); |
| @@ -29,6 +33,29 @@ export async function runGatewayTurn(input: RunGatewayTurnInput): Promise<RunTur · 7 unmodified lines | ||
| 29 | 33 | ); |
| 30 | 34 | } |
| 31 | 35 | |
| 36 | +export async function continueGatewayTurn(turnId: string): Promise<TurnResult> { | |
| 37 | + return Effect.runPromise( | |
| 38 | + Effect.gen(function* () { | |
| 39 | + const client = yield* RpcClient.make(AgentRpcs); | |
| 40 | + return yield* client.ContinueTurn({ turnId }); | |
| 41 | + }).pipe(Effect.scoped, Effect.provide(GatewayRpcClientLive)), | |
| 42 | + ); | |
| 43 | +} | |
| 44 | + | |
| 45 | +export async function resolveGatewayApproval(input: { | |
| 46 | + turnId: string; | |
| 47 | + callId: string; | |
| 48 | + actorId: string; | |
| 49 | + decision: "approved" | "denied"; | |
| 50 | +}): Promise<ResolveApprovalOutput> { | |
| 51 | + return Effect.runPromise( | |
| 52 | + Effect.gen(function* () { | |
| 53 | + const client = yield* RpcClient.make(AgentRpcs); | |
| 54 | + return yield* client.ResolveApproval(input); | |
| 55 | + }).pipe(Effect.scoped, Effect.provide(GatewayRpcClientLive)), | |
| 56 | + ); | |
| 57 | +} | |
| 58 | + | |
| 32 | 59 | function readEnv(key: string): string | undefined { |
| 33 | 60 | const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun; |
| 34 | 61 | return bun?.env?.[key] ?? process.env[key]; |
| @@ -9,14 +9,13 @@ import { · 8 unmodified lines | ||
| 9 | 9 | type SendableChannels, |
| 10 | 10 | } from "discord.js"; |
| 11 | 11 | import { Effect } from "effect"; |
| 12 | −import { AssistantReplyView, AssistantWorkingView } from "./discord-views.js"; | |
| 13 | −import { formatDiscordResponse } from "./format-response.js"; | |
| 14 | −import { runGatewayTurn } from "./gateway-client.js"; | |
| 12 | +import { AgentSessionView } from "./agent-session-view.js"; | |
| 15 | 13 | |
| 16 | 14 | const token = Bun.env.DISCORD_BOT_TOKEN; |
| 17 | 15 | if (!token) { |
| 18 | 16 | throw new Error("Missing DISCORD_BOT_TOKEN"); |
| 19 | 17 | } |
| 18 | +const approvalTimeoutMs = Number(Bun.env.OPENASSISTANT_APPROVAL_TIMEOUT_MS ?? 300_000); | |
| 20 | 19 | |
| 21 | 20 | const client = new Client({ |
| 22 | 21 | intents: [ |
| @@ -48,38 +47,26 @@ function shouldIgnore(message: Message): boolean { · 25 unmodified lines | ||
| 48 | 47 | } |
| 49 | 48 | |
| 50 | 49 | async function handleMessage(message: Message): Promise<void> { |
| 51 | − const approvalChannel = asSendableChannel(message.channel); | |
| 52 | − if (!approvalChannel) { | |
| 50 | + const replyChannel = asSendableChannel(message.channel); | |
| 51 | + if (!replyChannel) { | |
| 53 | 52 | await message.reply("This channel does not support assistant responses."); |
| 54 | 53 | return; |
| 55 | 54 | } |
| 56 | 55 | |
| 57 | − const instance = await Effect.runPromise( | |
| 58 | − reacord.send(approvalChannel, <AssistantWorkingView />, { | |
| 59 | − reply: { messageReference: message.id }, | |
| 60 | − }), | |
| 61 | − ); | |
| 62 | − | |
| 63 | − try { | |
| 64 | − const generated = await runGatewayTurn({ | |
| 65 | − prompt: message.content, | |
| 66 | − requesterId: message.author.id, | |
| 67 | − channelId: message.channelId, | |
| 68 | − }); | |
| 69 | − | |
| 70 | − const response = formatDiscordResponse({ | |
| 71 | − text: generated.message, | |
| 72 | − footer: generated.footer, | |
| 73 | − }); | |
| 74 | − | |
| 75 | − instance.render(<AssistantReplyView message={response.message} footer={response.footer} />); | |
| 76 | − } catch (error) { | |
| 77 | − instance.render( | |
| 78 | − <AssistantReplyView | |
| 79 | − message={`I hit an unexpected error while processing that request: ${describeUnknown(error)}`} | |
| 56 | + await Effect.runPromise( | |
| 57 | + reacord.send( | |
| 58 | + replyChannel, | |
| 59 | + <AgentSessionView | |
| 60 | + prompt={message.content} | |
| 61 | + requesterId={message.author.id} | |
| 62 | + channelId={message.channelId} | |
| 63 | + approvalTimeoutMs={approvalTimeoutMs} | |
| 80 | 64 | />, |
| 81 | − ); | |
| 82 | − } | |
| 65 | + { | |
| 66 | + reply: { messageReference: message.id }, | |
| 67 | + }, | |
| 68 | + ), | |
| 69 | + ); | |
| 83 | 70 | } |
| 84 | 71 | |
| 85 | 72 | function asSendableChannel(channel: unknown): SendableChannels | null { |
| @@ -91,10 +78,3 @@ function asSendableChannel(channel: unknown): SendableChannels | null { · 5 unmodified lines | ||
| 91 | 78 | } |
| 92 | 79 | return channel as SendableChannels; |
| 93 | 80 | } |
| 94 | − | |
| 95 | −function describeUnknown(error: unknown): string { | |
| 96 | − if (error instanceof Error) { | |
| 97 | − return error.message; | |
| 98 | − } | |
| 99 | − return String(error); | |
| 100 | −} | |
| @@ -3,7 +3,8 @@ · 2 unmodified lines | ||
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | − ".": "./src/index.ts" | |
| 6 | + ".": "./src/index.ts", | |
| 7 | + "./rpc": "./src/rpc.ts" | |
| 7 | 8 | }, |
| 8 | 9 | "scripts": { |
| 9 | 10 | "with-env": "bun run --env-file=../../.env --", |
| @@ -1,43 +1,67 @@ | ||
| 1 | 1 | import { HttpRouter } from "@effect/platform"; |
| 2 | 2 | import { BunHttpServer, BunRuntime } from "@effect/platform-bun"; |
| 3 | 3 | import { RpcSerialization, RpcServer } from "@effect/rpc"; |
| 4 | −import { AgentRpcs, RunTurnOutput, createCodeModeRunner } from "@openassistant/core"; | |
| 5 | 4 | import { Effect, Layer } from "effect"; |
| 6 | −import { runAgentLoop } from "./agent-loop.js"; | |
| 7 | 5 | import { InMemoryCalendarStore } from "./calendar-store.js"; |
| 6 | +import { AgentRpcs, ResolveApprovalOutput, type TurnResult } from "./rpc.js"; | |
| 8 | 7 | import { createToolTree } from "./tools.js"; |
| 8 | +import { TurnManager } from "./turn-manager.js"; | |
| 9 | 9 | |
| 10 | 10 | const port = Number(readEnv("OPENASSISTANT_GATEWAY_PORT") ?? "8787"); |
| 11 | 11 | |
| 12 | 12 | const calendarStore = new InMemoryCalendarStore(); |
| 13 | 13 | const tools = createToolTree(calendarStore); |
| 14 | −const runner = createCodeModeRunner({ | |
| 15 | − tools, | |
| 16 | − requestApproval: () => Effect.succeed("approved"), | |
| 17 | −}); | |
| 14 | +const turnManager = new TurnManager(tools, isVerboseMode()); | |
| 18 | 15 | |
| 19 | 16 | const AgentHandlersLive = AgentRpcs.toLayer( |
| 20 | 17 | Effect.succeed({ |
| 21 | − RunTurn: (input: { prompt: string; nowIso: string }) => | |
| 18 | + RunTurn: (input: { prompt: string; requesterId: string; channelId: string; nowIso: string }) => | |
| 22 | 19 | Effect.tryPromise({ |
| 23 | 20 | try: async () => { |
| 24 | − const generated = await runAgentLoop( | |
| 25 | − input.prompt, | |
| 26 | − (code) => Effect.runPromise(runner.run({ code })), | |
| 27 | − { | |
| 28 | − now: new Date(input.nowIso), | |
| 29 | − }, | |
| 30 | − ); | |
| 31 | − | |
| 32 | − return new RunTurnOutput({ | |
| 33 | − message: generated.text, | |
| 34 | − planner: generated.planner, | |
| 35 | − codeRuns: generated.runs.length, | |
| 36 | − ...(isVerboseMode() ? { footer: generated.planner } : {}), | |
| 21 | + const turnId = turnManager.start({ | |
| 22 | + prompt: input.prompt, | |
| 23 | + requesterId: input.requesterId, | |
| 24 | + channelId: input.channelId, | |
| 25 | + now: new Date(input.nowIso), | |
| 37 | 26 | }); |
| 27 | + const event = await turnManager.waitForNext(turnId); | |
| 28 | + if (!event) { | |
| 29 | + return { | |
| 30 | + status: "failed", | |
| 31 | + turnId, | |
| 32 | + error: "Turn not found.", | |
| 33 | + } as TurnResult; | |
| 34 | + } | |
| 35 | + return event; | |
| 38 | 36 | }, |
| 39 | 37 | catch: (error) => `RunTurn failed: ${describeUnknown(error)}`, |
| 40 | 38 | }), |
| 39 | + ContinueTurn: (input: { turnId: string }) => | |
| 40 | + Effect.tryPromise({ | |
| 41 | + try: async () => { | |
| 42 | + const event = await turnManager.waitForNext(input.turnId); | |
| 43 | + if (!event) { | |
| 44 | + return { | |
| 45 | + status: "failed", | |
| 46 | + turnId: input.turnId, | |
| 47 | + error: "Turn not found.", | |
| 48 | + } as TurnResult; | |
| 49 | + } | |
| 50 | + return event; | |
| 51 | + }, | |
| 52 | + catch: (error) => `ContinueTurn failed: ${describeUnknown(error)}`, | |
| 53 | + }), | |
| 54 | + ResolveApproval: (input: { turnId: string; callId: string; actorId: string; decision: "approved" | "denied" }) => | |
| 55 | + Effect.succeed( | |
| 56 | + new ResolveApprovalOutput({ | |
| 57 | + status: turnManager.resolveApproval({ | |
| 58 | + turnId: input.turnId, | |
| 59 | + callId: input.callId, | |
| 60 | + actorId: input.actorId, | |
| 61 | + decision: input.decision, | |
| 62 | + }), | |
| 63 | + }), | |
| 64 | + ), | |
| 41 | 65 | }), |
| 42 | 66 | ); |
| 43 | 67 | |
| @@ -0,0 +1,61 @@ | ||
| 1 | +import { Rpc, RpcGroup } from "@effect/rpc"; | |
| 2 | +import { Schema } from "effect"; | |
| 3 | + | |
| 4 | +export class RunTurnInput extends Schema.Class<RunTurnInput>("RunTurnInput")({ | |
| 5 | + prompt: Schema.String, | |
| 6 | + requesterId: Schema.String, | |
| 7 | + channelId: Schema.String, | |
| 8 | + nowIso: Schema.String, | |
| 9 | +}) {} | |
| 10 | + | |
| 11 | +export class ContinueTurnInput extends Schema.Class<ContinueTurnInput>("ContinueTurnInput")({ | |
| 12 | + turnId: Schema.String, | |
| 13 | +}) {} | |
| 14 | + | |
| 15 | +export class ResolveApprovalInput extends Schema.Class<ResolveApprovalInput>("ResolveApprovalInput")({ | |
| 16 | + turnId: Schema.String, | |
| 17 | + callId: Schema.String, | |
| 18 | + actorId: Schema.String, | |
| 19 | + decision: Schema.Literal("approved", "denied"), | |
| 20 | +}) {} | |
| 21 | + | |
| 22 | +export class ResolveApprovalOutput extends Schema.Class<ResolveApprovalOutput>("ResolveApprovalOutput")({ | |
| 23 | + status: Schema.Literal("resolved", "not_found", "unauthorized"), | |
| 24 | +}) {} | |
| 25 | + | |
| 26 | +export class ApprovalPrompt extends Schema.Class<ApprovalPrompt>("ApprovalPrompt")({ | |
| 27 | + callId: Schema.String, | |
| 28 | + toolPath: Schema.String, | |
| 29 | + inputPreview: Schema.optional(Schema.String), | |
| 30 | +}) {} | |
| 31 | + | |
| 32 | +export const TurnResultSchema = Schema.Struct({ | |
| 33 | + status: Schema.Literal("awaiting_approval", "completed", "failed"), | |
| 34 | + turnId: Schema.String, | |
| 35 | + approval: Schema.optional(ApprovalPrompt), | |
| 36 | + message: Schema.optional(Schema.String), | |
| 37 | + planner: Schema.optional(Schema.String), | |
| 38 | + codeRuns: Schema.optional(Schema.Number), | |
| 39 | + footer: Schema.optional(Schema.String), | |
| 40 | + error: Schema.optional(Schema.String), | |
| 41 | +}); | |
| 42 | + | |
| 43 | +export type TurnResult = Schema.Schema.Type<typeof TurnResultSchema>; | |
| 44 | + | |
| 45 | +export class AgentRpcs extends RpcGroup.make( | |
| 46 | + Rpc.make("RunTurn", { | |
| 47 | + payload: RunTurnInput, | |
| 48 | + success: TurnResultSchema, | |
| 49 | + error: Schema.String, | |
| 50 | + }), | |
| 51 | + Rpc.make("ContinueTurn", { | |
| 52 | + payload: ContinueTurnInput, | |
| 53 | + success: TurnResultSchema, | |
| 54 | + error: Schema.String, | |
| 55 | + }), | |
| 56 | + Rpc.make("ResolveApproval", { | |
| 57 | + payload: ResolveApprovalInput, | |
| 58 | + success: ResolveApprovalOutput, | |
| 59 | + error: Schema.String, | |
| 60 | + }), | |
| 61 | +) {} | |
| @@ -0,0 +1,209 @@ | ||
| 1 | +import { | |
| 2 | + createCodeModeRunner, | |
| 3 | + type ApprovalDecision, | |
| 4 | + type ApprovalRequest, | |
| 5 | + type ToolTree, | |
| 6 | +} from "@openassistant/core"; | |
| 7 | +import type { AgentLoopResult } from "./agent-loop.js"; | |
| 8 | +import { runAgentLoop } from "./agent-loop.js"; | |
| 9 | +import { type TurnResult } from "./rpc.js"; | |
| 10 | +import { Effect } from "effect"; | |
| 11 | + | |
| 12 | +type PendingApproval = { | |
| 13 | + callId: string; | |
| 14 | + toolPath: string; | |
| 15 | + inputPreview?: string; | |
| 16 | + resolve: (decision: ApprovalDecision) => void; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +type TurnEvent = TurnResult; | |
| 20 | + | |
| 21 | +type TurnSession = { | |
| 22 | + id: string; | |
| 23 | + requesterId: string; | |
| 24 | + channelId: string; | |
| 25 | + queue: TurnEvent[]; | |
| 26 | + waitingResolver: ((event: TurnEvent) => void) | null; | |
| 27 | + pendingApproval: PendingApproval | null; | |
| 28 | + approvalWaiters: Array<() => void>; | |
| 29 | + completed: boolean; | |
| 30 | +}; | |
| 31 | + | |
| 32 | +export type ResolveApprovalStatus = "resolved" | "not_found" | "unauthorized"; | |
| 33 | + | |
| 34 | +export class TurnManager { | |
| 35 | + private readonly sessions = new Map<string, TurnSession>(); | |
| 36 | + | |
| 37 | + constructor( | |
| 38 | + private readonly tools: ToolTree, | |
| 39 | + private readonly verboseFooter: boolean, | |
| 40 | + ) {} | |
| 41 | + | |
| 42 | + start(params: { | |
| 43 | + prompt: string; | |
| 44 | + requesterId: string; | |
| 45 | + channelId: string; | |
| 46 | + now: Date; | |
| 47 | + }): string { | |
| 48 | + const id = newTurnId(); | |
| 49 | + const session: TurnSession = { | |
| 50 | + id, | |
| 51 | + requesterId: params.requesterId, | |
| 52 | + channelId: params.channelId, | |
| 53 | + queue: [], | |
| 54 | + waitingResolver: null, | |
| 55 | + pendingApproval: null, | |
| 56 | + approvalWaiters: [], | |
| 57 | + completed: false, | |
| 58 | + }; | |
| 59 | + this.sessions.set(id, session); | |
| 60 | + this.runSession(session, params.prompt, params.now); | |
| 61 | + return id; | |
| 62 | + } | |
| 63 | + | |
| 64 | + async waitForNext(turnId: string): Promise<TurnEvent | null> { | |
| 65 | + const session = this.sessions.get(turnId); | |
| 66 | + if (!session) { | |
| 67 | + return null; | |
| 68 | + } | |
| 69 | + if (session.queue.length > 0) { | |
| 70 | + const next = session.queue.shift()!; | |
| 71 | + this.cleanupIfTerminal(session, next); | |
| 72 | + return next; | |
| 73 | + } | |
| 74 | + | |
| 75 | + const event = await new Promise<TurnEvent>((resolve) => { | |
| 76 | + session.waitingResolver = resolve; | |
| 77 | + }); | |
| 78 | + this.cleanupIfTerminal(session, event); | |
| 79 | + return event; | |
| 80 | + } | |
| 81 | + | |
| 82 | + resolveApproval(params: { | |
| 83 | + turnId: string; | |
| 84 | + callId: string; | |
| 85 | + actorId: string; | |
| 86 | + decision: ApprovalDecision; | |
| 87 | + }): ResolveApprovalStatus { | |
| 88 | + const session = this.sessions.get(params.turnId); | |
| 89 | + if (!session) { | |
| 90 | + return "not_found"; | |
| 91 | + } | |
| 92 | + if (session.requesterId !== params.actorId) { | |
| 93 | + return "unauthorized"; | |
| 94 | + } | |
| 95 | + if (!session.pendingApproval || session.pendingApproval.callId !== params.callId) { | |
| 96 | + return "not_found"; | |
| 97 | + } | |
| 98 | + | |
| 99 | + const pending = session.pendingApproval; | |
| 100 | + session.pendingApproval = null; | |
| 101 | + pending.resolve(params.decision); | |
| 102 | + const nextWaiter = session.approvalWaiters.shift(); | |
| 103 | + if (nextWaiter) { | |
| 104 | + nextWaiter(); | |
| 105 | + } | |
| 106 | + return "resolved"; | |
| 107 | + } | |
| 108 | + | |
| 109 | + private async runSession(session: TurnSession, prompt: string, now: Date): Promise<void> { | |
| 110 | + try { | |
| 111 | + const runner = createCodeModeRunner({ | |
| 112 | + tools: this.tools, | |
| 113 | + requestApproval: (request) => | |
| 114 | + Effect.tryPromise({ | |
| 115 | + try: () => this.requestApproval(session, request), | |
| 116 | + catch: (error) => error, | |
| 117 | + }), | |
| 118 | + }); | |
| 119 | + | |
| 120 | + const generated = await runAgentLoop( | |
| 121 | + prompt, | |
| 122 | + (code) => Effect.runPromise(runner.run({ code })), | |
| 123 | + { now }, | |
| 124 | + ); | |
| 125 | + | |
| 126 | + this.emitEvent(session, toCompletedEvent(session.id, generated, this.verboseFooter)); | |
| 127 | + } catch (error) { | |
| 128 | + this.emitEvent(session, { | |
| 129 | + status: "failed", | |
| 130 | + turnId: session.id, | |
| 131 | + error: describeUnknown(error), | |
| 132 | + }); | |
| 133 | + } finally { | |
| 134 | + session.completed = true; | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + private async requestApproval(session: TurnSession, request: ApprovalRequest): Promise<ApprovalDecision> { | |
| 139 | + await this.waitForApprovalSlot(session); | |
| 140 | + | |
| 141 | + return new Promise<ApprovalDecision>((resolve) => { | |
| 142 | + session.pendingApproval = { | |
| 143 | + callId: request.callId, | |
| 144 | + toolPath: request.toolPath, | |
| 145 | + ...(request.inputPreview ? { inputPreview: request.inputPreview } : {}), | |
| 146 | + resolve, | |
| 147 | + }; | |
| 148 | + | |
| 149 | + this.emitEvent(session, { | |
| 150 | + status: "awaiting_approval", | |
| 151 | + turnId: session.id, | |
| 152 | + approval: { | |
| 153 | + callId: request.callId, | |
| 154 | + toolPath: request.toolPath, | |
| 155 | + ...(request.inputPreview ? { inputPreview: request.inputPreview } : {}), | |
| 156 | + }, | |
| 157 | + }); | |
| 158 | + }); | |
| 159 | + } | |
| 160 | + | |
| 161 | + private async waitForApprovalSlot(session: TurnSession): Promise<void> { | |
| 162 | + while (session.pendingApproval) { | |
| 163 | + await new Promise<void>((resolve) => { | |
| 164 | + session.approvalWaiters.push(resolve); | |
| 165 | + }); | |
| 166 | + } | |
| 167 | + } | |
| 168 | + | |
| 169 | + private emitEvent(session: TurnSession, event: TurnEvent): void { | |
| 170 | + if (session.waitingResolver) { | |
| 171 | + const resolve = session.waitingResolver; | |
| 172 | + session.waitingResolver = null; | |
| 173 | + resolve(event); | |
| 174 | + return; | |
| 175 | + } | |
| 176 | + session.queue.push(event); | |
| 177 | + } | |
| 178 | + | |
| 179 | + private cleanupIfTerminal(session: TurnSession, event: TurnEvent): void { | |
| 180 | + if ((event.status === "completed" || event.status === "failed") && session.completed) { | |
| 181 | + this.sessions.delete(session.id); | |
| 182 | + } | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +function toCompletedEvent(turnId: string, generated: AgentLoopResult, includeFooter: boolean): TurnResult { | |
| 187 | + return { | |
| 188 | + status: "completed", | |
| 189 | + turnId, | |
| 190 | + message: generated.text, | |
| 191 | + planner: generated.planner, | |
| 192 | + codeRuns: generated.runs.length, | |
| 193 | + ...(includeFooter ? { footer: generated.planner } : {}), | |
| 194 | + }; | |
| 195 | +} | |
| 196 | + | |
| 197 | +function newTurnId(): string { | |
| 198 | + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { | |
| 199 | + return crypto.randomUUID(); | |
| 200 | + } | |
| 201 | + return `turn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; | |
| 202 | +} | |
| 203 | + | |
| 204 | +function describeUnknown(error: unknown): string { | |
| 205 | + if (error instanceof Error) { | |
| 206 | + return error.message; | |
| 207 | + } | |
| 208 | + return String(error); | |
| 209 | +} | |
| @@ -20,6 +20,7 @@ · 19 unmodified lines | ||
| 20 | 20 | "@effect/platform": "latest", |
| 21 | 21 | "@effect/rpc": "latest", |
| 22 | 22 | "@openassistant/core": "workspace:*", |
| 23 | + "@openassistant/gateway": "workspace:*", | |
| 23 | 24 | "@openassistant/reacord": "workspace:*", |
| 24 | 25 | "ai": "^6.0.73", |
| 25 | 26 | "discord.js": "^14.17.3", |
| @@ -47,7 +48,6 @@ · 21 unmodified lines | ||
| 47 | 48 | "packages/core": { |
| 48 | 49 | "name": "@openassistant/core", |
| 49 | 50 | "dependencies": { |
| 50 | − "@effect/rpc": "latest", | |
| 51 | 51 | "effect": "latest", |
| 52 | 52 | }, |
| 53 | 53 | }, |
| @@ -12,7 +12,6 @@ · 11 unmodified lines | ||
| 12 | 12 | "clean": "bun -e \"import { rmSync } from 'node:fs'; for (const p of ['dist','node_modules','.turbo','coverage','.tsbuildinfo']) rmSync(p,{recursive:true,force:true});\"" |
| 13 | 13 | }, |
| 14 | 14 | "dependencies": { |
| 15 | − "@effect/rpc": "latest", | |
| 16 | 15 | "effect": "latest" |
| 17 | 16 | } |
| 18 | 17 | } |
| @@ -1,24 +0,0 @@ | ||
| 1 | −import { Rpc, RpcGroup } from "@effect/rpc"; | |
| 2 | −import { Schema } from "effect"; | |
| 3 | − | |
| 4 | −export class RunTurnInput extends Schema.Class<RunTurnInput>("RunTurnInput")({ | |
| 5 | − prompt: Schema.String, | |
| 6 | − requesterId: Schema.String, | |
| 7 | − channelId: Schema.String, | |
| 8 | − nowIso: Schema.String, | |
| 9 | −}) {} | |
| 10 | − | |
| 11 | −export class RunTurnOutput extends Schema.Class<RunTurnOutput>("RunTurnOutput")({ | |
| 12 | − message: Schema.String, | |
| 13 | − planner: Schema.String, | |
| 14 | − codeRuns: Schema.Number, | |
| 15 | − footer: Schema.optional(Schema.String), | |
| 16 | −}) {} | |
| 17 | − | |
| 18 | −export class AgentRpcs extends RpcGroup.make( | |
| 19 | − Rpc.make("RunTurn", { | |
| 20 | − payload: RunTurnInput, | |
| 21 | − success: RunTurnOutput, | |
| 22 | − error: Schema.String, | |
| 23 | − }), | |
| 24 | −) {} | |
| @@ -2,4 +2,3 @@ import { Effect } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | export const healthcheck = Effect.succeed("ok"); |
| 4 | 4 | export * from "./codemode/runner.js"; |
| 5 | −export * from "./agent-rpc.js"; | |