Skip to content

Commit 7d31dd8

Browse files
ThomasK33claude
andauthored
fix(agent): mark server tool calls dynamic so the tool loop continues past them (#15)
* fix(agent): mark server tool calls dynamic so the tool loop continues past them TurnTranslator emitted chatd's server-side tool calls (web_search, advisor, …) with providerExecuted: true but without dynamic: true. The AI SDK only tolerates tool names missing from the client ToolSet on providerExecuted && dynamic calls; without the flag each server tool call was marked `invalid`, which injects a phantom tool-error output and halts the tool loop on that step (the output/call count check fails). Any turn where a server tool ran alongside a pending client tool call therefore ended with finishReason "tool-calls" instead of continuing — the client tool's result was never submitted and the chat stranded in requires_action. Observed live on dev.coder.com: a structured_output client tool turn ended after 1 step whenever chatd fired its advisor/web_search tools. With the flag, the same turn completes naturally (2 steps, finishReason "stop"). Change-Id: If42835eecc7f6e91153b4dada9b9d36ddc53db61 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com> * docs(agent): add structured output guide and runnable example Distills the structured_output tool pattern (proven in coder-agents-workflows) for getting schema-typed answers out of a Coder Agent run: the Zod schema becomes the inputSchema of a client-executed structured_output tool, the model files its answer by calling it, and the object arrives as the call's typed input — no JSON-in-prose parsing. - README "Structured output" section: provider-vs-agent routing, a compact sketch, and the four robustness rules (no toolChoice force / no hasToolCall stop, client-side safeParse, one nudge on an idle chat only, settle a turn that stopped on the call so the chat doesn't strand in requires_action). - examples/06-structured-output.ts: copyable structuredOutput(schema) helper implementing the settle + one-nudge ladder, plus archiveQuietly cleanup that tolerates the post-settle wind-down. Verified live against dev.coder.com. Change-Id: Ieddce1790812ee15920b13c8a3d89d6f4829fbe7 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com> * ci: match the actions/cache pin comment to its exact tag (v5.0.5) zizmor 1.25.2 fails ref-version-mismatch on every PR since upstream's moving v5 tag advanced past the pinned commit: 27d5ce7f is tag v5.0.5, while v5 now points at caa29612. Pin comments must name the exact tag the hash resolves to. Change-Id: Ie11f20be5af490177f782009fb876d067e41a41e Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com> * fix(agent): mirror dynamic flag on server tool results Codex review: calls were emitted dynamic:true but their results were not, so the AI SDK filed the call under dynamicToolCalls and the result in the static bucket — breaking call/result pairing in steps[*].dynamicToolResults and UI message streams (streamText propagates the part's own flag). Change-Id: I11845cc4b9cdaa1947d9d58b2847906c6c78c8ab Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com> * docs(agent): note that turn.toolCalls is the final step's calls at the settle site Codex review round 3 misread the settle as spanning all steps. In ai v6 GenerateTextResult.toolCalls returns finalStep.toolCalls, so the settle covers exactly the stranded segment; document that where it matters. Change-Id: I426df9197151aeff5de601970fbc74697c51e38d Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com> * fix(agent): harden translate orphan results and structured-output example per max review Max-effort workflow review findings: - translate.ts: drop a server tool-result whose call streamed in a previous segment — emitted call-less it makes ai v6's asContent throw "Tool call <id> not found" mid-generate (path newly reachable now that mixed client+server segments continue past the call). Unit-tested. - example: settle now submits the stranded step's locally-executed results for ALL client tools (steps.at(-1); the ack for structured_output, real results for others) and interrupts when a pending call has no local result or the settle POST fails — the chat is never left wedged for reuse/cleanup. Replaces the comment-only "single client tool" invariant. - example: archiveQuietly bounds every attempt (8s race), retries only wind-down outcomes (409 / attempt timeout), fails fast on 401/403/404. - example: type the client slice as Pick<CoderChatClient, "submitToolResults"> instead of a hand-rolled signature copy; use || for the model env var so a set-but-empty value falls back; set requestTimeoutMs so a wedged segment fails loudly. - README: the snippet now fails clearly when the model never called structured_output instead of throwing an opaque ZodError; rule 4 generalized to settling all client calls with interrupt fallback. Change-Id: I0bbec405230aa25e265cbc7b8e3d488d5e586dc7 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com> * fix(agent): settle submits local tool errors as errors, matching the resume path Codex review: the settle sent every locally-executed outcome with is_error: false, but the normal resume path (toolResultOutputToChatd) marks error-text/error-json/execution-denied outputs as errors. Build the settle answers from the stranded step's content — tool-result parts as successes, tool-error parts as is_error: true — so a throwing execute is recorded as a failure instead of a success (or needlessly interrupting). Change-Id: I86dca9ae0a7168da70a00d58d7abbf07c1d6e16d Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com> --------- Signed-off-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0ddfedb commit 7d31dd8

7 files changed

Lines changed: 423 additions & 17 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ jobs:
5555
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
5656

5757
- name: Cache pnpm store
58-
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
58+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
5959
with:
6060
path: ${{ env.STORE_PATH }}
6161
key: ${{ runner.os }}-node${{ matrix.node }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}

packages/agent/README.md

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ pnpm example:stream # streaming via textStream
9696
pnpm example:tool # custom (client-executed) tool round-trip
9797
pnpm example:multi-turn # multi-turn session memory
9898
pnpm example:file # attach a file to a chat (optional: pass a path)
99+
pnpm example:structured # typed structured output via the structured_output tool
99100
```
100101

101102
Each example creates a new chat and archives it when done — it never touches workspaces. See
@@ -285,13 +286,89 @@ retrying the whole step deliberately.
285286

286287
## Structured output
287288

288-
`CoderAgent` does **not** constrain output to a JSON schema — chatd has no
289-
server‑side `response_format`, so a `responseFormat` / `experimental_output`
290-
request emits a warning and is best‑effort at most. For reliable
291-
schema‑constrained output, use **[`@coder/ai-sdk-provider`](../provider)** with
292-
`generateObject` / `Output.object` (requires AI Gateway enabled on the
293-
deployment). Use the Agent for the steps that need server‑side tools; use the
294-
provider for pure text‑in / JSON‑out steps.
289+
Coder Agents has no server‑side `response_format`, so `CoderAgent` cannot
290+
constrain what the model **says** to a JSON schema — a `responseFormat` /
291+
`experimental_output` request emits a warning and is best‑effort at most. Pick
292+
by what the step needs:
293+
294+
- **Pure text‑in / JSON‑out, no server‑side tools** → use
295+
**[`@coder/ai-sdk-provider`](../provider)** with `generateObject` /
296+
`Output.object` (schema‑constrained; requires AI Gateway on the deployment).
297+
- **The answer must come out of an agent run** (server‑side tools, MCP, a
298+
workspace) → use the **`structured_output` tool pattern** below. What the
299+
model _says_ isn't schema‑constrained, but what it passes **into a tool** is
300+
typed — so have it submit its answer by _calling a tool_ whose `inputSchema`
301+
is your Zod schema. The answer arrives as the tool call's typed `input`; no
302+
fishing JSON out of prose.
303+
304+
```ts
305+
import { stepCountIs, tool } from "ai";
306+
import { z } from "zod";
307+
308+
const Answer = z.object({ severity: z.enum(["critical", "major", "minor"]), summary: z.string() });
309+
310+
const agent = new CoderAgent({
311+
/**/
312+
instructions: "… Submit your final answer by calling the structured_output tool exactly once.",
313+
tools: {
314+
structured_output: tool({
315+
description:
316+
"Submit your final structured answer as JSON. Call this exactly once, when your work is complete.",
317+
inputSchema: Answer, // your schema IS the tool's input schema
318+
// Ack instead of stopping the turn: the model finishes naturally and can
319+
// wind down anything it still has running (dev servers, watchers, …).
320+
execute: async () =>
321+
"Output received. Wind down and end your turn. Do not call structured_output again.",
322+
}),
323+
},
324+
stopWhen: stepCountIs(6), // happy path is 2 steps: file + ack, wind down
325+
});
326+
327+
const result = await agent.generate({ prompt: "" });
328+
// toolCalls only holds the LAST step's calls — scan all steps, last call wins.
329+
const raw = result.steps
330+
.flatMap((s) => s.toolCalls)
331+
.findLast((c) => c.toolName === "structured_output")?.input;
332+
if (raw === undefined)
333+
throw new Error("model never called structured_output — nudge once on an idle chat (rule 3)");
334+
const answer = Answer.parse(raw); // typed: { severity: "critical" | "major" | "minor"; summary: string }
335+
```
336+
337+
Rules that keep it robust — each guards against a failure mode observed live:
338+
339+
1. **Don't force `toolChoice`, don't stop on the call.** `toolChoice` is
340+
construction‑time and applies to _every_ segment, so after the ack it would
341+
force the tool again and again up to the step ceiling (and it blocks any
342+
other tools the step needs). A `hasToolCall` stop is worse: the server only
343+
receives a client tool result as a side effect of the _next_ loop segment,
344+
so ending the loop on the call strands the chat in `requires_action`
345+
follow‑up messages queue forever and `archive()` 409s. Instructions plus the
346+
tool's own description are enough; models file unprompted most of the time.
347+
2. **Validate client‑side.** The schema is not enforced server‑side —
348+
`schema.safeParse` on the tool input is the real gate. (Schema‑invalid calls
349+
that the AI SDK catches in‑loop are automatically answered with a
350+
`tool-error` result the model retries against.)
351+
3. **Nudge at most once, and only an idle chat.** If the turn ends in prose
352+
(`finishReason: "stop"`) without a valid call, send one typed re‑prompt
353+
("Call the structured_output tool now …"), then fail into your normal error
354+
handling. Never re‑prompt a chat that isn't idle — the message would queue
355+
behind whatever the server is still doing.
356+
4. **Settle a turn that stopped on a tool call.** If the loop stops on a
357+
tool‑call step — e.g. your `stopWhen` ceiling lands exactly on the
358+
`structured_output` call (`finishReason: "tool-calls"`) — the tool results
359+
ran locally but never reached the server. Submit the stranded step's
360+
(`result.steps.at(-1)`) locally‑executed client results directly via
361+
`agent.client.submitToolResults(agent.chatId, { results: [{ tool_call_id, output }] })`
362+
before touching the chat again, or it strands as in rule 1; if a pending
363+
call has no local result (or the submit fails), `agent.interrupt()` ends
364+
the stranded turn instead. A settled chat resumes its wind‑down server‑side
365+
for a few seconds, so retry a 409ing `archive()` under a short deadline
366+
instead of giving up.
367+
368+
[`examples/06-structured-output.ts`](./examples/06-structured-output.ts) packages
369+
all four rules into a small copyable helper — `structuredOutput(schema)` returns
370+
`agentOpts` to spread into the constructor plus a typed `ask(agent, prompt)`
371+
that runs the settle + one‑nudge ladder and returns a `z.infer<typeof schema>`.
295372

296373
## Workspaces & quota
297374

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
// Structured (typed) output from an agent run — the `structured_output` tool pattern.
2+
//
3+
// Coder Agents has no server-side `response_format`, so you cannot schema-constrain
4+
// what the model SAYS. Tool inputs are the reliable channel instead: register a
5+
// client-executed tool whose `inputSchema` IS your Zod schema and instruct the model
6+
// to submit its final answer by CALLING it. The answer arrives as the tool call's
7+
// typed `input` — no fishing JSON out of prose. (For pure text-in/JSON-out steps
8+
// with no server-side tools, prefer @coder/ai-sdk-provider + generateObject.)
9+
//
10+
// tsx examples/06-structured-output.ts (or: pnpm example:structured)
11+
import { stepCountIs, tool } from "ai";
12+
import { z } from "zod";
13+
import { CoderAgent, CoderApiError, type CoderChatClient } from "../src/index.js";
14+
import { heading, loadEnv } from "./_shared.js";
15+
16+
// ── The helper (copy this into your project) ─────────────────────────────────
17+
// Bind the schema ONCE: it becomes the tool's input schema AND the client-side
18+
// parse gate, so the two can never drift apart.
19+
20+
/** The tool result the model sees after filing. Returning an ack — instead of
21+
* stopping the turn at the call — lets the turn complete naturally, so the model
22+
* can wind down anything it still has running (dev servers, watchers) first. */
23+
const ACK =
24+
"Output received. You may now gracefully shut down anything you still have running, " +
25+
"then end your turn. Do not call structured_output again.";
26+
27+
const NUDGE =
28+
"You have not submitted a valid structured_output call for this request. Call the " +
29+
"structured_output tool now with your final answer as JSON matching the tool's input schema exactly.";
30+
31+
/** The slice of `CoderAgent` the helper reads (structural, so tests can fake it).
32+
* The turn shape is structural because the AI SDK types it per-ToolSet; the client
33+
* reuses the package's own signature so it cannot drift. */
34+
type StructuredAgent = {
35+
generate(opts: { prompt: string }): Promise<{
36+
finishReason: string;
37+
steps: Array<{
38+
toolCalls: Array<{
39+
toolCallId: string;
40+
toolName: string;
41+
input: unknown;
42+
providerExecuted?: boolean;
43+
}>;
44+
// The step's content parts: tool-result (execute succeeded) and tool-error
45+
// (execute threw) both carry the local outcome the settle must submit.
46+
content: Array<{ type: string; toolCallId?: string; output?: unknown; error?: unknown }>;
47+
}>;
48+
}>;
49+
readonly chatId: string | undefined;
50+
readonly client: Pick<CoderChatClient, "submitToolResults">;
51+
interrupt(): Promise<void>;
52+
};
53+
54+
function structuredOutput<T>(schema: z.ZodType<T>, opts: { maxSteps?: number } = {}) {
55+
return {
56+
// Spread into `new CoderAgent({ … })`. Deliberately NO `toolChoice` force (it is
57+
// construction-time, so it would re-force the tool on every segment after the ack)
58+
// and NO `hasToolCall` stop (ending the loop on the call strands the chat — see
59+
// the settle step in ask()). The happy path is two steps: file + ack, wind down.
60+
agentOpts: {
61+
tools: {
62+
structured_output: tool({
63+
description:
64+
"Submit your final structured answer as JSON. Call this exactly once, when your work is complete.",
65+
inputSchema: schema,
66+
execute: async () => ACK,
67+
}),
68+
},
69+
stopWhen: stepCountIs(opts.maxSteps ?? 6),
70+
},
71+
72+
/** Run one prompt and return the schema-validated answer. */
73+
async ask(agent: StructuredAgent, prompt: string): Promise<T> {
74+
for (const p of [prompt, NUDGE]) {
75+
const turn = await agent.generate({ prompt: p });
76+
77+
// SETTLE. The server receives a client tool result only as a side effect of
78+
// the NEXT loop segment. If the loop stopped ON a tool-call segment (the
79+
// stopWhen ceiling landed there → finishReason "tool-calls"), the results ran
80+
// locally but never reached the server: the chat is stuck in
81+
// `requires_action` — new messages queue behind it, archive() 409s. Submit
82+
// the stranded step's locally-executed client outcomes directly (for
83+
// structured_output that is the ack; any other client tool gets its real
84+
// result, and a throwing execute is submitted as an ERROR — mirroring what
85+
// the resume path would have sent). `steps.at(-1)` is exactly the stranded
86+
// segment — earlier steps' calls were answered by their own resume segments.
87+
// Note: this direct submit bypasses the SDK's own submitted-ids bookkeeping,
88+
// so if you later continue the session by replaying `messages`, prefer a
89+
// fresh chat/session instead.
90+
const last = turn.steps.at(-1);
91+
const pending =
92+
turn.finishReason === "tool-calls"
93+
? (last?.toolCalls ?? []).filter((c) => !c.providerExecuted)
94+
: [];
95+
const outcomes = new Map<string, { output: unknown; isError: boolean }>();
96+
for (const part of last?.content ?? []) {
97+
if (part.toolCallId === undefined) continue;
98+
if (part.type === "tool-result") {
99+
outcomes.set(part.toolCallId, { output: part.output ?? null, isError: false });
100+
} else if (part.type === "tool-error") {
101+
outcomes.set(part.toolCallId, { output: String(part.error), isError: true });
102+
}
103+
}
104+
const answerable = pending.filter((c) => outcomes.has(c.toolCallId));
105+
let settled = true;
106+
if (answerable.length > 0 && agent.chatId) {
107+
settled = await agent.client
108+
.submitToolResults(
109+
agent.chatId,
110+
{
111+
results: answerable.map((c) => {
112+
const outcome = outcomes.get(c.toolCallId);
113+
return {
114+
tool_call_id: c.toolCallId,
115+
output: outcome?.output ?? null,
116+
is_error: outcome?.isError ?? false,
117+
};
118+
}),
119+
},
120+
AbortSignal.timeout(8_000), // a stalled settle must not wedge the caller
121+
)
122+
.then(
123+
() => true,
124+
(err) => {
125+
// Best-effort: a settle failure must never mask an answer we can read.
126+
console.warn("structured_output settle failed:", err);
127+
return false;
128+
},
129+
);
130+
}
131+
// A pending call with no local outcome (e.g. an approval-gated call) cannot
132+
// be answered; same if the settle POST failed. Interrupt so the stranded turn
133+
// ends and the chat is safe to archive or reuse instead of wedged forever.
134+
if (pending.length > answerable.length) settled = false;
135+
if (!settled) await agent.interrupt().catch(() => {});
136+
137+
console.log(
138+
` [ask] turn finished "${turn.finishReason}" after ${turn.steps.length} step(s)` +
139+
(pending.length > 0
140+
? ` — settled ${answerable.length}/${pending.length} pending call(s)${settled ? "" : ", interrupted"}`
141+
: ""),
142+
);
143+
144+
// READ. The answer is the tool call's INPUT. Scan every step, last call wins
145+
// (a re-filed answer supersedes an earlier one). The server does NOT enforce
146+
// the schema, so safeParse is the real gate — schema-invalid calls the AI SDK
147+
// catches in-loop are already retried against a tool-error result automatically.
148+
const report = turn.steps
149+
.flatMap((s) => s.toolCalls)
150+
.findLast((c) => c.toolName === "structured_output")?.input;
151+
if (report !== undefined) {
152+
const parsed = schema.safeParse(report);
153+
if (parsed.success) return parsed.data;
154+
}
155+
156+
// NUDGE — at most once, and only on an idle chat (the turn ended in prose,
157+
// finishReason "stop"). Never re-prompt a settled turn: its wind-down is still
158+
// running server-side, so the nudge would queue behind it — and contradict the
159+
// ack the model just received. Fail into your normal error handling instead
160+
// (an unsettled chat was interrupted above, so cleanup can archive it).
161+
if (turn.finishReason !== "stop" || !settled) {
162+
throw new Error(
163+
`no structured_output answer (turn finished: ${turn.finishReason}) — not re-prompting a busy chat`,
164+
);
165+
}
166+
}
167+
throw new Error("no valid structured_output call after one nudge");
168+
},
169+
};
170+
}
171+
172+
/** Sentinel rejection used by {@link bounded} so callers can tell "attempt timed
173+
* out" (retryable — the server may just be busy) apart from a real API error. */
174+
const TIMED_OUT = new Error("attempt timed out");
175+
176+
/** Race work against a per-attempt deadline. Both outcomes of `work` stay handled,
177+
* so a late loser can never become an unhandled rejection. */
178+
function bounded<T>(work: Promise<T>, ms: number): Promise<T> {
179+
return new Promise<T>((resolve, reject) => {
180+
const timer = setTimeout(() => reject(TIMED_OUT), ms);
181+
work.then(
182+
(value) => {
183+
clearTimeout(timer);
184+
resolve(value);
185+
},
186+
(err) => {
187+
clearTimeout(timer);
188+
reject(err);
189+
},
190+
);
191+
});
192+
}
193+
194+
/** Cleanup that tolerates a chat still winding down. A settled (or interrupted)
195+
* turn resumes server-side for a few seconds, and archive() 409s until the chat
196+
* parks — so interrupt and retry under a deadline instead of giving up on the
197+
* first attempt (a bare `archive()` or `await using` would leak the chat here).
198+
* Retries cover only the wind-down outcomes: a 409, or a timed-out attempt.
199+
* Anything else (401/403/404, network down) will not heal — warn and stop. */
200+
async function archiveQuietly(agent: {
201+
interrupt(): Promise<void>;
202+
archive(): Promise<void>;
203+
}): Promise<void> {
204+
const deadline = Date.now() + 15_000;
205+
for (;;) {
206+
try {
207+
await bounded(agent.archive(), 8_000);
208+
return;
209+
} catch (err) {
210+
const stillWindingDown =
211+
err === TIMED_OUT || (err instanceof CoderApiError && err.status === 409);
212+
if (!stillWindingDown || Date.now() > deadline) {
213+
console.warn("could not archive the chat, leaving it:", err);
214+
return;
215+
}
216+
await bounded(agent.interrupt(), 8_000).catch(() => {}); // stop whatever is still running, then retry
217+
await new Promise((resolve) => setTimeout(resolve, 1_000));
218+
}
219+
}
220+
}
221+
222+
// ── Usage ─────────────────────────────────────────────────────────────────────
223+
224+
const TriageSchema = z.object({
225+
severity: z.enum(["critical", "major", "minor"]),
226+
component: z.string().describe("the subsystem at fault"),
227+
summary: z.string().describe("one-sentence diagnosis"),
228+
reproSteps: z.array(z.string()),
229+
});
230+
231+
const BUG_REPORT = `
232+
Since the 2.3.1 update, clicking "Export CSV" on the billing dashboard downloads
233+
an empty file. The network tab shows /api/billing/export returning 200 with
234+
content-length: 0. Logging out and back in does not help. Exports from the audit
235+
page still work fine.
236+
`;
237+
238+
const { baseUrl, token, organizationId } = await loadEnv();
239+
// Tool-calling is more reliable on a stronger model; override with CODER_TOOL_MODEL.
240+
// (`||`, not `??`: a set-but-empty env var should fall back too.)
241+
const model = process.env.CODER_TOOL_MODEL || "sonnet";
242+
243+
const so = structuredOutput(TriageSchema);
244+
const agent = new CoderAgent({
245+
baseUrl,
246+
token,
247+
organizationId,
248+
model,
249+
// Other client tools compose fine: ask()'s settle answers every pending client
250+
// call from its locally-executed result. Server-side tools are unaffected.
251+
instructions:
252+
"You triage bug reports. Submit your final answer by calling the structured_output tool exactly once.",
253+
// Bound each server segment so a wedged turn fails loudly instead of hanging.
254+
requestTimeoutMs: 120_000,
255+
...so.agentOpts,
256+
});
257+
258+
try {
259+
heading("structured output via the structured_output tool");
260+
const triage = await so.ask(agent, `Triage this bug report:\n${BUG_REPORT}`);
261+
262+
// `triage` is fully typed: severity is "critical" | "major" | "minor", etc.
263+
console.log("Severity :", triage.severity);
264+
console.log("Component :", triage.component);
265+
console.log("Summary :", triage.summary);
266+
console.log("Repro steps :", triage.reproSteps.length > 0 ? triage.reproSteps : "(none given)");
267+
console.log("Chat id :", agent.chatId);
268+
} finally {
269+
await archiveQuietly(agent);
270+
}

0 commit comments

Comments
 (0)