| 24 | 24 | | import { CHAT_MAX_HOPS } from "../../../packages/contracts/src/chat.ts"; |
| 25 | 25 | | import type { Tokens } from "./budget.ts"; |
| 26 | 26 | | import { handOffPort } from "./handoff.ts"; |
| 27 | | − | import { HISTORY_LIMIT, fixedHello, helloAsk, systemPrompt, turns } from "./prompt.ts"; |
| 27 | + | import { HISTORY_LIMIT, systemPrompt, turns } from "./prompt.ts"; |
| 28 | 28 | | import { loadShelf, skillsSection, teamSlugs } from "./skills.ts"; |
| 29 | 29 | | import { readVersion } from "./skill-library.ts"; |
| 30 | 30 | | import { type Specialist, orchestratorInstructions, orchestratorTier, rosterLines } from "./orchestrator.ts"; |
| ⋯ |
| 226 | 226 | | effort?: string | null; |
| 227 | 227 | | }; |
| 228 | 228 | | |
| 229 | | − | /** |
| 230 | | − | * What a desk is handed: a message to answer, or (`hello`) the agent's |
| 231 | | − | * first message to the person who made it, in the DM that just opened. |
| 232 | | − | */ |
| 233 | | − | export type DeskWork = AgentDelivery & { hello?: boolean }; |
| 229 | + | /** What a desk is handed: a message to answer. */ |
| 230 | + | export type DeskWork = AgentDelivery; |
| 234 | 231 | | |
| 235 | 232 | | /** How a limit's refusal reads in chat, in the agent's voice. */ |
| 236 | 233 | | function noticeFor(reason: string, message: string): string { |
| ⋯ |
| 317 | 314 | | |
| 318 | 315 | | /** Says once, in this conversation, why the agent cannot answer; a repeat within hours is kept back. */ |
| 319 | 316 | | const notice = async (message: string, reason: string) => { |
| 320 | | − | // An agent's first words are never an excuse: without a model, a fixed hello. |
| 321 | | − | if (delivery.hello) { |
| 322 | | − | const posted = await surface.post(fixedHello(row, delivery.asker?.username ?? null)).catch(() => null); |
| 323 | | − | return await finish({ status: "blocked", error: reason, reply_id: posted }); |
| 324 | | − | } |
| 325 | 317 | | const since = new Date(now.getTime() - NOTICE_QUIET_MS).toISOString(); |
| 326 | 318 | | const recent = await db |
| 327 | 319 | | .prepare( |
| ⋯ |
| 336 | 328 | | |
| 337 | 329 | | try { |
| 338 | 330 | | // A reply under one of its session cards steers that session; nothing is answered here. |
| 339 | | − | const steered = delivery.hello ? null : await steeredSession(db, row.id, delivery.thread_root); |
| 331 | + | const steered = await steeredSession(db, row.id, delivery.thread_root); |
| 340 | 332 | | if (steered) { |
| 341 | 333 | | const history = await surface.history(HISTORY_LIMIT).catch(() => [] as SurfaceMessage[]); |
| 342 | 334 | | const message = history.find((m) => m.id === delivery.message_id); |
| ⋯ |
| 347 | 339 | | } |
| 348 | 340 | | |
| 349 | 341 | | // Read the conversation while showing that the agent is on it. |
| 350 | | − | // A hello has no conversation yet: it is asked to introduce itself. |
| 351 | 342 | | // And who is here: said every turn, so the agent knows who reads it and who doesn't. |
| 352 | 343 | | // And its teams, from their pages: who leads, who owns what, who is around. |
| 353 | 344 | | const [, history, conversationHere, teamsHere] = await Promise.all([ |
| 354 | 345 | | surface.typing(), |
| 355 | | − | delivery.hello ? Promise.resolve([]) : surface.history(HISTORY_LIMIT), |
| 356 | | − | delivery.hello ? Promise.resolve(null) : surface.conversation(), |
| 346 | + | surface.history(HISTORY_LIMIT), |
| 347 | + | surface.conversation(), |
| 357 | 348 | | loadTeams(env, slug, row.workspace_id, { id: row.id }).catch(() => null), |
| 358 | 349 | | ]); |
| 359 | 350 | | // A personal agent answers only its member, in the DM of the two of them (access.ts). |
| ⋯ |
| 363 | 354 | | members: conversationHere ? conversationHere.people + conversationHere.agents : null, |
| 364 | 355 | | }); |
| 365 | 356 | | if (refused) return await notice(refused, "personal_agent"); |
| 366 | | − | const conversation = delivery.hello ? [{ role: "user" as const, content: helloAsk(delivery.asker?.username ?? null) }] : turns(history, row.id); |
| 357 | + | const conversation = turns(history, row.id); |
| 367 | 358 | | if (!conversation.length) return await finish({ status: "skipped", error: "nothing to answer" }); |
| 368 | 359 | | const author = askerIn(history, delivery); |
| 369 | 360 | | const askerName = delivery.asker?.username ?? author?.name ?? null; |
| ⋯ |
| 387 | 378 | | // The "Your abilities outside g1t" section, once the tool box has them. |
| 388 | 379 | | let abilitiesText: string | null = null; |
| 389 | 380 | | let place: RecallPlace = { channel_id: delivery.channel_id, kind: delivery.channel_kind === "dm" ? "dm" : "private", people: [delivery.asked_by] }; |
| 390 | | − | if (!delivery.hello) { |
| 391 | | − | try { |
| 392 | | − | const audience = await Audience.build(slug, delivery.asked_by, audiencePorts(env, slug, delivery.channel_id)); |
| 393 | | − | place = { channel_id: delivery.channel_id, kind: audience.kind, people: audience.shared ? [delivery.asked_by] : audience.members.map((m) => m.id) }; |
| 394 | | − | const ports = (consult: ToolPorts["consult"]) => toolPorts(env, slug, row.workspace_id, delivery.channel_id, consult, row.id); |
| 395 | | − | audienceHash = audience.hash; |
| 396 | | − | const consult = consulting({ |
| 397 | | − | db, |
| 398 | | − | row, |
| 399 | | − | delivery, |
| 400 | | − | send: model.send, |
| 401 | | − | policy: model.policy, |
| 402 | | − | ownModel: model.ownModel, |
| 403 | | − | sessionModel: model.sessionModel, |
| 404 | | − | own: model.own, |
| 405 | | − | ports, |
| 406 | | − | hops, |
| 407 | | − | now, |
| 408 | | − | surface, |
| 409 | | − | asker, |
| 410 | | − | spent: consulted, |
| 411 | | − | }); |
| 412 | | − | const where = delivery.channel_kind === "dm" ? "a direct message" : `#${delivery.channel_name ?? "a channel"}`; |
| 413 | | − | const actions = actionPorts(env as unknown as SessionEnv, { |
| 414 | | − | agent: row, |
| 415 | | − | place, |
| 416 | | − | source: { kind: "message", ref: delivery.message_id, label: askerName ? `@${askerName} in ${where}` : where, channel_id: delivery.channel_id }, |
| 417 | | − | asker: { id: delivery.asked_by, username: askerName }, |
| 418 | | − | workspace: slug, |
| 419 | | − | postCard: (card) => surface.post("", card).catch(() => null), |
| 420 | | − | // Real work becomes a session, with its card in this conversation. |
| 421 | | − | spinOff: async (title, goal) => { |
| 422 | | − | if (spinOffs >= MAX_SPIN_OFFS) return { ok: false, message: "You've started enough sessions from this message." }; |
| 423 | | − | spinOffs++; |
| 424 | | − | const session = await startSession(env as unknown as SessionEnv, { |
| 381 | + | try { |
| 382 | + | const audience = await Audience.build(slug, delivery.asked_by, audiencePorts(env, slug, delivery.channel_id)); |
| 383 | + | place = { channel_id: delivery.channel_id, kind: audience.kind, people: audience.shared ? [delivery.asked_by] : audience.members.map((m) => m.id) }; |
| 384 | + | const ports = (consult: ToolPorts["consult"]) => toolPorts(env, slug, row.workspace_id, delivery.channel_id, consult, row.id); |
| 385 | + | audienceHash = audience.hash; |
| 386 | + | const consult = consulting({ |
| 387 | + | db, |
| 388 | + | row, |
| 389 | + | delivery, |
| 390 | + | send: model.send, |
| 391 | + | policy: model.policy, |
| 392 | + | ownModel: model.ownModel, |
| 393 | + | sessionModel: model.sessionModel, |
| 394 | + | own: model.own, |
| 395 | + | ports, |
| 396 | + | hops, |
| 397 | + | now, |
| 398 | + | surface, |
| 399 | + | asker, |
| 400 | + | spent: consulted, |
| 401 | + | }); |
| 402 | + | const where = delivery.channel_kind === "dm" ? "a direct message" : `#${delivery.channel_name ?? "a channel"}`; |
| 403 | + | const actions = actionPorts(env as unknown as SessionEnv, { |
| 404 | + | agent: row, |
| 405 | + | place, |
| 406 | + | source: { kind: "message", ref: delivery.message_id, label: askerName ? `@${askerName} in ${where}` : where, channel_id: delivery.channel_id }, |
| 407 | + | asker: { id: delivery.asked_by, username: askerName }, |
| 408 | + | workspace: slug, |
| 409 | + | postCard: (card) => surface.post("", card).catch(() => null), |
| 410 | + | // Real work becomes a session, with its card in this conversation. |
| 411 | + | spinOff: async (title, goal) => { |
| 412 | + | if (spinOffs >= MAX_SPIN_OFFS) return { ok: false, message: "You've started enough sessions from this message." }; |
| 413 | + | spinOffs++; |
| 414 | + | const session = await startSession(env as unknown as SessionEnv, { |
| 415 | + | agent: row, |
| 416 | + | kind: "chat", |
| 417 | + | title, |
| 418 | + | goal: `${askerName ? `@${askerName}` : "Someone"} asked in ${where}:\n\n${goal}`, |
| 419 | + | workspace: slug, |
| 420 | + | channel_id: delivery.channel_id, |
| 421 | + | channel_kind: delivery.channel_kind, |
| 422 | + | channel_name: delivery.channel_name, |
| 423 | + | thread_root: delivery.thread_root, |
| 424 | + | message_id: delivery.message_id, |
| 425 | + | asked_by: delivery.asked_by, |
| 426 | + | asked_by_username: askerName, |
| 427 | + | asker: delivery.asker ?? null, |
| 428 | + | chain: [...chain], |
| 429 | + | hops, |
| 430 | + | }); |
| 431 | + | const cap = session.cap_micros ? ` with a cap of ${dollars(session.cap_micros)}` : ""; |
| 432 | + | return { ok: true, message: `Started the session "${session.title}"${cap}. Its card is in the conversation and it reports back there. Tell them in a sentence; don't do the work here.` }; |
| 433 | + | }, |
| 434 | + | }); |
| 435 | + | // Work handed to a colleague: the only way an agent gets another working (handoff.ts). |
| 436 | + | const viewer = audience.asker; |
| 437 | + | actions.handOff = handOffPort({ |
| 438 | + | self: { id: row.id, handle: row.handle }, |
| 439 | + | chain, |
| 440 | + | asker: delivery.asker ?? null, |
| 441 | + | agent: async (handle) => { |
| 442 | + | const found = await db |
| 443 | + | .prepare(selectAgents("a.workspace_id = ?3 AND a.handle = ?4 AND a.archived_at IS NULL AND a.scope = 'workspace'")) |
| 444 | + | .bind(...periods(now), row.workspace_id, handle) |
| 445 | + | .first<Row>(); |
| 446 | + | if (!found) return null; |
| 447 | + | const agent = toAgent(found, now); |
| 448 | + | return { id: agent.id, handle: agent.handle, display_name: agent.display_name, builtin: !!found.builtin, status: agent.status }; |
| 449 | + | }, |
| 450 | + | person: async (handle) => { |
| 451 | + | if (!viewer) return false; |
| 452 | + | const members = await identityClient(env.IDENTITY).listMembers(slug, viewer); |
| 453 | + | return members.ok && members.value.some((m) => m.username.toLowerCase() === handle); |
| 454 | + | }, |
| 455 | + | handOff: (colleagueId, brief) => surface.handOff(colleagueId, brief), |
| 456 | + | }); |
| 457 | + | toolbox = new ToolBox( |
| 458 | + | audience, |
| 459 | + | ports(consult.ask), |
| 460 | + | { agentId: row.id, notConsult: [row.handle, ...(sender ? [sender.handle] : [])], hops, maxHops: CHAT_MAX_HOPS }, |
| 461 | + | [], |
| 462 | + | actions, |
| 463 | + | ); |
| 464 | + | consult.attach(toolbox); |
| 465 | + | // Its abilities outside g1t (abilities.ts): offered and enforced by the tool box, for the person it acts for. |
| 466 | + | if (viewer) { |
| 467 | + | const sections = await abilitiesFor(env as unknown as AbilityEnv, { agent: row, definition, workspace: slug, asker: viewer }).catch(() => null); |
| 468 | + | if (sections) { |
| 469 | + | const saidByPeople = saidText([...history].reverse().filter((m) => m.author.kind === "user").slice(0, 3).map((m) => m.body)); |
| 470 | + | toolbox.useAbilities( |
| 471 | + | sections, |
| 472 | + | abilityPorts(env as unknown as AbilityEnv, { |
| 425 | 473 | | agent: row, |
| 426 | | − | kind: "chat", |
| 427 | | − | title, |
| 428 | | − | goal: `${askerName ? `@${askerName}` : "Someone"} asked in ${where}:\n\n${goal}`, |
| 429 | 474 | | workspace: slug, |
| 430 | 475 | | channel_id: delivery.channel_id, |
| 431 | | − | channel_kind: delivery.channel_kind, |
| 432 | | − | channel_name: delivery.channel_name, |
| 433 | | − | thread_root: delivery.thread_root, |
| 434 | | − | message_id: delivery.message_id, |
| 435 | | − | asked_by: delivery.asked_by, |
| 436 | | − | asked_by_username: askerName, |
| 437 | | − | asker: delivery.asker ?? null, |
| 438 | | − | chain: [...chain], |
| 439 | | − | hops, |
| 440 | | − | }); |
| 441 | | − | const cap = session.cap_micros ? ` with a cap of ${dollars(session.cap_micros)}` : ""; |
| 442 | | − | return { ok: true, message: `Started the session "${session.title}"${cap}. Its card is in the conversation and it reports back there. Tell them in a sentence; don't do the work here.` }; |
| 443 | | − | }, |
| 444 | | − | }); |
| 445 | | − | // Work handed to a colleague: the only way an agent gets another working (handoff.ts). |
| 446 | | − | const viewer = audience.asker; |
| 447 | | − | actions.handOff = handOffPort({ |
| 448 | | − | self: { id: row.id, handle: row.handle }, |
| 449 | | − | chain, |
| 450 | | − | asker: delivery.asker ?? null, |
| 451 | | − | agent: async (handle) => { |
| 452 | | − | const found = await db |
| 453 | | − | .prepare(selectAgents("a.workspace_id = ?3 AND a.handle = ?4 AND a.archived_at IS NULL AND a.scope = 'workspace'")) |
| 454 | | − | .bind(...periods(now), row.workspace_id, handle) |
| 455 | | − | .first<Row>(); |
| 456 | | − | if (!found) return null; |
| 457 | | − | const agent = toAgent(found, now); |
| 458 | | − | return { id: agent.id, handle: agent.handle, display_name: agent.display_name, builtin: !!found.builtin, status: agent.status }; |
| 459 | | − | }, |
| 460 | | − | person: async (handle) => { |
| 461 | | − | if (!viewer) return false; |
| 462 | | − | const members = await identityClient(env.IDENTITY).listMembers(slug, viewer); |
| 463 | | − | return members.ok && members.value.some((m) => m.username.toLowerCase() === handle); |
| 464 | | − | }, |
| 465 | | − | handOff: (colleagueId, brief) => surface.handOff(colleagueId, brief), |
| 466 | | − | }); |
| 467 | | − | toolbox = new ToolBox( |
| 468 | | − | audience, |
| 469 | | − | ports(consult.ask), |
| 470 | | − | { agentId: row.id, notConsult: [row.handle, ...(sender ? [sender.handle] : [])], hops, maxHops: CHAT_MAX_HOPS }, |
| 471 | | − | [], |
| 472 | | − | actions, |
| 473 | | − | ); |
| 474 | | − | consult.attach(toolbox); |
| 475 | | − | // Its abilities outside g1t (abilities.ts): offered and enforced by the tool box, for the person it acts for. |
| 476 | | − | if (viewer && !delivery.hello) { |
| 477 | | − | const sections = await abilitiesFor(env as unknown as AbilityEnv, { agent: row, definition, workspace: slug, asker: viewer }).catch(() => null); |
| 478 | | − | if (sections) { |
| 479 | | − | const saidByPeople = saidText([...history].reverse().filter((m) => m.author.kind === "user").slice(0, 3).map((m) => m.body)); |
| 480 | | − | toolbox.useAbilities( |
| 481 | | − | sections, |
| 482 | | − | abilityPorts(env as unknown as AbilityEnv, { |
| 483 | | − | agent: row, |
| 484 | | − | workspace: slug, |
| 485 | | − | channel_id: delivery.channel_id, |
| 486 | | − | session: null, |
| 487 | | − | asker: { id: delivery.asked_by, username: askerName }, |
| 488 | | − | postCard: (card) => surface.post("", card).catch(() => null), |
| 489 | | − | }), |
| 490 | | − | saidByPeople, |
| 491 | | − | definition.abilities.mcp_servers, |
| 492 | | − | ); |
| 493 | | − | abilitiesText = abilitiesSection(sections); |
| 494 | | − | } |
| 476 | + | session: null, |
| 477 | + | asker: { id: delivery.asked_by, username: askerName }, |
| 478 | + | postCard: (card) => surface.post("", card).catch(() => null), |
| 479 | + | }), |
| 480 | + | saidByPeople, |
| 481 | + | definition.abilities.mcp_servers, |
| 482 | + | ); |
| 483 | + | abilitiesText = abilitiesSection(sections); |
| 495 | 484 | | } |
| 496 | | − | } catch (error) { |
| 497 | | − | // Without an audience nothing may be read: the reply goes on with this conversation only. |
| 498 | | − | console.error("agents: no audience for a reply, so no tools", row.id, String(error)); |
| 499 | 485 | | } |
| 486 | + | } catch (error) { |
| 487 | + | // Without an audience nothing may be read: the reply goes on with this conversation only. |
| 488 | + | console.error("agents: no audience for a reply, so no tools", row.id, String(error)); |
| 500 | 489 | | } |
| 501 | 490 | | // What people said last, for recalling what the workspace's artifacts say about it. |
| 502 | 491 | | const said = [...history].reverse().filter((m) => m.author.kind === "user").slice(0, 3).map((m) => m.body); |
| 503 | | − | const [facts, recent, passages, shelf] = delivery.hello |
| 504 | | − | ? [[], null, [], []] |
| 505 | | − | : await Promise.all([ |
| 492 | + | const [facts, recent, passages, shelf] = await Promise.all([ |
| 506 | 493 | | recall(db, row.id, place).catch(() => []), |
| 507 | 494 | | sessionsHere(db, row.id, delivery.channel_id).catch(() => null), |
| 508 | 495 | | toolbox ? toolbox.recall(recallQuery(said), definition.reading ?? []) : Promise.resolve([]), |