mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
refactor(x): decouple the new runtime from the legacy engine file
Findings from a fresh post-refactor review. The turn-runtime bridges imported convertFromMessages and getToolPermissionMetadata from agents/runtime.ts — the legacy engine file — making it undeletable; both move to neutral modules (agents/message-encoding.ts, agents/permission-metadata.ts) that both engines now share. The abort registry, used by the live tool registry, moves out of the legacy runs/ cluster into turns/. Dead surface goes: the zero-consumer CopilotInstructions, skillCatalog, RunLogger, and MappedToolCall exports are deleted, mapAgentTool/StreamStepMessageBuilder become module-private, and the mid-file import scar from the composer extraction is hoisted. Three review fixes ride along: ITurnEventBus gains subscribe/subscribeAll so consumers stop resolving the concrete hub; skill carry-forward becomes its own registry trait instead of overloading workspaceContext; and connection checks (slack/composio/ code-mode/google) collapse into one shared connections.ts consumed by both skill availability and the copilot prompt blocks — plus stale doc pointers (VIDEO_MODE.md to moved prompt text, CLAUDE.md's dangling AGENTS.md reference). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6375704d66
commit
fdce639839
26 changed files with 449 additions and 445 deletions
|
|
@ -111,7 +111,7 @@ Long-form docs for specific features. Read the relevant file before making chang
|
|||
| Live Notes — single `live:` frontmatter block (one objective + optional cron / windows / eventMatchCriteria) that turns a note into a self-updating artifact, panel UI, Copilot skill, prompts catalog | `apps/x/LIVE_NOTE.md` |
|
||||
| Calls (video mode) — one hands-free call engine with four presets (voice / video / share screen / practice coaching), device-derived surfaces (full-screen ⇄ floating popout), frame pipeline, prompts catalog | `apps/x/VIDEO_MODE.md` |
|
||||
| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` |
|
||||
| Turn/session runtime — event-sourced storage, reference model, the `npm run inspect` debugger | `AGENTS.md` (repo root), `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` |
|
||||
| Turn/session runtime — event-sourced storage, reference model, the `npm run inspect` debugger | `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ is live) to the outgoing message as `UserImagePart`s and sets
|
|||
(`data`, `mediaType`), `source: 'camera' | 'screen'`, `capturedAt`. Unlike
|
||||
file attachments (path references read via the `LLMParse` tool), image
|
||||
parts go to the model as real multimodal image parts.
|
||||
- `packages/core/src/agents/runtime.ts` `convertFromMessages` (~line 1013):
|
||||
- `packages/core/src/agents/message-encoding.ts` `convertFromMessages`:
|
||||
emits a context line (frame counts + time span), then labeled groups —
|
||||
a `"Webcam frames (oldest to newest):"` text part before camera images and
|
||||
a `"Screen-share frames (oldest to newest):"` text part before screen
|
||||
|
|
@ -176,8 +176,8 @@ Push-to-talk is disabled while a call owns the mic.
|
|||
|
||||
| Prompt | Where |
|
||||
|--------|-------|
|
||||
| `# Video Mode (Live Camera)` system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) | `packages/core/src/agents/runtime.ts:386` (`composeSystemInstructions`, gated on `videoMode`) |
|
||||
| `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `composeSystemInstructions`, gated on `coachMode` (directly after the video section) |
|
||||
| `# Video Mode (Live Camera)` system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) | `packages/core/src/application/assistant/capabilities/modes.ts` (the `VIDEO_MODE` fragment of the `video-mode` capability, composed by `agents/compose-instructions.ts`) |
|
||||
| `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `capabilities/modes.ts` (the `COACH_MODE` fragment, directly after the video capability) |
|
||||
| "Driving the app" paragraph in the video-mode section — on calls, prefer app-navigation read-view/open-item (show while telling) over describing or squinting at frames | same `# Video Mode` section; full action docs in the `app-navigation` skill (`application/assistant/skills/app-navigation/skill.ts`) |
|
||||
| Per-message frame context line `[Video mode: N live webcam frames … and M frames of the user's shared screen …]` + group labels | `packages/core/src/agents/runtime.ts` (`convertFromMessages`) |
|
||||
| `videoMode` / `coachMode` composition overrides (session-sticky; flips bust prefix cache) | `packages/core/src/turns/bridges/real-agent-resolver.ts` (`CompositionOverrides`); set from `App.tsx` `sendConfig` |
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
|||
import type { SessionBusEvent } from '@x/shared/dist/sessions.js';
|
||||
import { isDurableTurnEvent } from '@x/shared/dist/turns.js';
|
||||
import type { ISessions, EmitterSessionBus } from '@x/core/dist/sessions/index.js';
|
||||
import type { TurnEventHub } from '@x/core/dist/turns/event-hub.js';
|
||||
import type { ITurnEventBus } from '@x/core/dist/turns/event-hub.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
|
||||
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
|
||||
|
|
@ -728,7 +728,7 @@ export function startTurnEventsWatcher(): void {
|
|||
if (turnEventsWatcher) {
|
||||
return;
|
||||
}
|
||||
const hub = container.resolve<TurnEventHub>('turnEventBus');
|
||||
const hub = container.resolve<ITurnEventBus>('turnEventBus');
|
||||
turnEventsWatcher = hub.subscribeAll((event) => {
|
||||
if (isDurableTurnEvent(event.event)) {
|
||||
broadcastToWindows('turns:events', event);
|
||||
|
|
|
|||
187
apps/x/packages/core/src/agents/message-encoding.ts
Normal file
187
apps/x/packages/core/src/agents/message-encoding.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
// Structural→wire message encoding: converts stored conversation messages
|
||||
// into AI SDK ModelMessages (user-context weaving, attachment rendering,
|
||||
// tool-result enveloping). Deterministic per message, so composed requests
|
||||
// stay byte-stable. Shared by both engines — extracted from the legacy
|
||||
// engine file so the turn-runtime bridges no longer depend on it.
|
||||
|
||||
import { ModelMessage } from "ai";
|
||||
import { Message, UserMessageContext } from "@x/shared/dist/message.js";
|
||||
import { z } from "zod";
|
||||
|
||||
function formatUserMessageContextForLlm(userMessageContext: z.infer<typeof UserMessageContext>): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (userMessageContext.currentDateTime) {
|
||||
sections.push(`Current date and time: ${userMessageContext.currentDateTime}`);
|
||||
}
|
||||
|
||||
if (userMessageContext.middlePane) {
|
||||
if (userMessageContext.middlePane.kind === 'empty') {
|
||||
sections.push(`Middle pane:\nState: empty`);
|
||||
} else if (userMessageContext.middlePane.kind === 'note') {
|
||||
sections.push(`Middle pane:\nState: note\nPath: ${userMessageContext.middlePane.path}\n\nContent:\n\`\`\`\n${userMessageContext.middlePane.content}\n\`\`\``);
|
||||
} else {
|
||||
sections.push(`Middle pane:\nState: browser\nURL: ${userMessageContext.middlePane.url}\nTitle: ${userMessageContext.middlePane.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `# User Context
|
||||
${sections.join('\n\n')}
|
||||
|
||||
# User Message
|
||||
`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
||||
const result: ModelMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
const { providerOptions } = msg;
|
||||
switch (msg.role) {
|
||||
case "assistant":
|
||||
if (typeof msg.content === 'string') {
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: msg.content,
|
||||
providerOptions,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: msg.content.map(part => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return part;
|
||||
case 'reasoning':
|
||||
return part;
|
||||
case 'tool-call':
|
||||
return {
|
||||
type: 'tool-call',
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
input: part.arguments,
|
||||
providerOptions: part.providerOptions,
|
||||
};
|
||||
}
|
||||
}),
|
||||
providerOptions,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "system":
|
||||
result.push({
|
||||
role: "system",
|
||||
content: msg.content,
|
||||
providerOptions,
|
||||
});
|
||||
break;
|
||||
case "user": {
|
||||
const userMessageContextPrefix = msg.userMessageContext ? formatUserMessageContextForLlm(msg.userMessageContext) : '';
|
||||
if (typeof msg.content === 'string') {
|
||||
// Legacy string — pass through unchanged
|
||||
result.push({
|
||||
role: "user",
|
||||
content: `${userMessageContextPrefix}${msg.content}`,
|
||||
providerOptions,
|
||||
});
|
||||
} else {
|
||||
// New content parts array — collapse text/attachments to text
|
||||
// for the LLM; inline image parts (video-mode webcam and
|
||||
// screen-share frames) are passed through as real multimodal
|
||||
// image parts, grouped under labeled text headers so the
|
||||
// model knows which images show the user vs their screen.
|
||||
const textSegments: string[] = userMessageContextPrefix ? [userMessageContextPrefix] : [];
|
||||
const attachmentLines: string[] = [];
|
||||
type EncodedImagePart = { type: "image"; image: string; mediaType: string };
|
||||
const cameraParts: EncodedImagePart[] = [];
|
||||
const screenParts: EncodedImagePart[] = [];
|
||||
const frameTimes: string[] = [];
|
||||
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "attachment") {
|
||||
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
|
||||
const lineStr = part.lineNumber ? ` (line ${part.lineNumber})` : '';
|
||||
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}${lineStr}`);
|
||||
} else if (part.type === "image") {
|
||||
const target = part.source === "screen" ? screenParts : cameraParts;
|
||||
target.push({ type: "image", image: part.data, mediaType: part.mediaType });
|
||||
if (part.capturedAt) frameTimes.push(part.capturedAt);
|
||||
} else {
|
||||
textSegments.push(part.text);
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentLines.length > 0) {
|
||||
if (userMessageContextPrefix) {
|
||||
textSegments.push("User has attached the following files:", ...attachmentLines, "");
|
||||
} else {
|
||||
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
|
||||
}
|
||||
}
|
||||
|
||||
const imageCount = cameraParts.length + screenParts.length;
|
||||
if (imageCount > 0) {
|
||||
const span = frameTimes.length >= 2
|
||||
? ` spanning ${frameTimes[0]} to ${frameTimes[frameTimes.length - 1]}`
|
||||
: frameTimes.length === 1
|
||||
? ` captured at ${frameTimes[0]}`
|
||||
: '';
|
||||
const kinds: string[] = [];
|
||||
if (cameraParts.length > 0) kinds.push(`${cameraParts.length} live webcam frame${cameraParts.length === 1 ? '' : 's'} of the user`);
|
||||
if (screenParts.length > 0) kinds.push(`${screenParts.length} frame${screenParts.length === 1 ? '' : 's'} of the user's shared screen`);
|
||||
textSegments.push(`[Video mode: ${kinds.join(' and ')} attached below, each group oldest to newest,${span ? span + ',' : ''} recorded while they composed this message.]`);
|
||||
const content: Array<{ type: "text"; text: string } | EncodedImagePart> = [
|
||||
{ type: "text", text: textSegments.join("\n") },
|
||||
];
|
||||
if (cameraParts.length > 0) {
|
||||
content.push({ type: "text", text: "Webcam frames (oldest to newest):" }, ...cameraParts);
|
||||
}
|
||||
if (screenParts.length > 0) {
|
||||
content.push({ type: "text", text: "Screen-share frames (oldest to newest):" }, ...screenParts);
|
||||
}
|
||||
result.push({
|
||||
role: "user",
|
||||
content,
|
||||
providerOptions,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
role: "user",
|
||||
content: textSegments.join("\n"),
|
||||
providerOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool":
|
||||
result.push({
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
output: {
|
||||
type: "text",
|
||||
value: msg.content,
|
||||
},
|
||||
},
|
||||
],
|
||||
providerOptions,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
// doing this because: https://github.com/OpenRouterTeam/ai-sdk-provider/issues/262
|
||||
return JSON.parse(JSON.stringify(result));
|
||||
}
|
||||
139
apps/x/packages/core/src/agents/permission-metadata.ts
Normal file
139
apps/x/packages/core/src/agents/permission-metadata.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Deterministic tool-permission metadata: decides whether a builtin tool
|
||||
// call needs human approval (command allowlist, workspace file boundaries,
|
||||
// file-access grants). Shared by both engines — extracted from the legacy
|
||||
// engine file so the turn-runtime bridges no longer depend on it.
|
||||
|
||||
import path from "path";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { ToolAttachment } from "@x/shared/dist/agent.js";
|
||||
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||
import { z } from "zod";
|
||||
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
||||
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
|
||||
import { resolveFilePathForPermission } from "../filesystem/files.js";
|
||||
|
||||
type ToolPermissionMetadataValue = z.infer<typeof ToolPermissionMetadata>;
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function fileGrantCoversPath(grant: FileAccessGrant, operation: FileAccessOperation, resolvedPath: string): boolean {
|
||||
return grant.operation === operation && isPathInside(path.resolve(grant.pathPrefix), path.resolve(resolvedPath));
|
||||
}
|
||||
|
||||
function commonPathPrefix(paths: string[]): string {
|
||||
if (!paths.length) return path.resolve(WorkDir);
|
||||
const split = paths.map(p => path.resolve(p).split(path.sep).filter(Boolean));
|
||||
const first = split[0];
|
||||
const common: string[] = [];
|
||||
for (let i = 0; i < first.length; i++) {
|
||||
if (split.every(parts => parts[i] === first[i])) {
|
||||
common.push(first[i]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const prefix = `${path.sep}${common.join(path.sep)}`;
|
||||
return prefix === path.sep ? prefix : path.resolve(prefix);
|
||||
}
|
||||
|
||||
function grantPrefixForTool(toolName: string, resolvedPaths: string[]): string {
|
||||
if (toolName === 'file-list' || toolName === 'file-glob' || toolName === 'file-grep' || toolName === 'file-mkdir') {
|
||||
return commonPathPrefix(resolvedPaths);
|
||||
}
|
||||
const parentPaths = resolvedPaths.map(p => path.dirname(p));
|
||||
return commonPathPrefix(parentPaths);
|
||||
}
|
||||
|
||||
function filePermissionTargets(toolName: string, args: Record<string, unknown>): { operation: FileAccessOperation; paths: string[] } | null {
|
||||
const pathArg = typeof args.path === 'string' ? args.path : undefined;
|
||||
switch (toolName) {
|
||||
case 'file-readText':
|
||||
case 'parseFile':
|
||||
case 'LLMParse':
|
||||
case 'file-exists':
|
||||
case 'file-stat':
|
||||
return pathArg ? { operation: 'read', paths: [pathArg] } : null;
|
||||
case 'file-list':
|
||||
return pathArg ? { operation: 'list', paths: [pathArg || '.'] } : null;
|
||||
case 'file-glob':
|
||||
return { operation: 'search', paths: [typeof args.cwd === 'string' && args.cwd ? args.cwd : '.'] };
|
||||
case 'file-grep':
|
||||
return { operation: 'search', paths: [typeof args.searchPath === 'string' && args.searchPath ? args.searchPath : '.'] };
|
||||
case 'file-writeText':
|
||||
case 'file-editText':
|
||||
case 'file-mkdir':
|
||||
return pathArg ? { operation: 'write', paths: [pathArg] } : null;
|
||||
case 'file-copy':
|
||||
case 'file-rename': {
|
||||
const from = typeof args.from === 'string' ? args.from : undefined;
|
||||
const to = typeof args.to === 'string' ? args.to : undefined;
|
||||
return from && to ? { operation: 'write', paths: [from, to] } : null;
|
||||
}
|
||||
case 'file-remove':
|
||||
return pathArg ? { operation: 'delete', paths: [pathArg] } : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getToolPermissionMetadata(
|
||||
toolCall: z.infer<typeof ToolCallPart>,
|
||||
underlyingTool: z.infer<typeof ToolAttachment>,
|
||||
sessionAllowedCommands: Set<string>,
|
||||
sessionAllowedFileAccess: FileAccessGrant[],
|
||||
): Promise<ToolPermissionMetadataValue | null> {
|
||||
if (underlyingTool.type !== 'builtin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (underlyingTool.name === 'executeCommand') {
|
||||
const args = toolCall.arguments;
|
||||
if (!args || typeof args !== 'object' || !('command' in args)) {
|
||||
return null;
|
||||
}
|
||||
const command = String((args as { command: unknown }).command);
|
||||
if (!isBlocked(command, sessionAllowedCommands)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: 'command',
|
||||
commandNames: extractCommandNames(command),
|
||||
};
|
||||
}
|
||||
|
||||
const args = toolCall.arguments && typeof toolCall.arguments === 'object'
|
||||
? toolCall.arguments as Record<string, unknown>
|
||||
: {};
|
||||
const targets = filePermissionTargets(underlyingTool.name, args);
|
||||
if (!targets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedTargets = await Promise.all(targets.paths.map(p => resolveFilePathForPermission(p)));
|
||||
const outsideWorkspacePaths = resolvedTargets
|
||||
.filter(target => !target.isInsideWorkspace)
|
||||
.map(target => target.canonicalPath);
|
||||
if (!outsideWorkspacePaths.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistentGrants = getFileAccessAllowList();
|
||||
const allGrants = [...persistentGrants, ...sessionAllowedFileAccess];
|
||||
const uncovered = outsideWorkspacePaths.filter(resolvedPath =>
|
||||
!allGrants.some(grant => fileGrantCoversPath(grant, targets.operation, resolvedPath))
|
||||
);
|
||||
if (!uncovered.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'file',
|
||||
operation: targets.operation,
|
||||
paths: uncovered,
|
||||
pathPrefix: grantPrefixForTool(underlyingTool.name, uncovered),
|
||||
};
|
||||
}
|
||||
|
|
@ -23,6 +23,10 @@ export interface AgentTraits {
|
|||
// Receives workspace context — agent notes and the user work directory —
|
||||
// composed into its system prompt.
|
||||
workspaceContext?: boolean;
|
||||
// Session-loaded skills (activeSkills) re-attach their tools on later
|
||||
// turns. Distinct from workspaceContext: a trait per concern, so neither
|
||||
// silently inherits the other's meaning.
|
||||
skillCarryForward?: boolean;
|
||||
}
|
||||
|
||||
interface BuiltinAgentDefinition {
|
||||
|
|
@ -51,7 +55,7 @@ function promptFileAgent(id: string, getRaw: () => string): BuiltinAgentDefiniti
|
|||
// definition object.
|
||||
const COPILOT: BuiltinAgentDefinition = {
|
||||
build: buildCopilotAgent,
|
||||
traits: { workspaceContext: true },
|
||||
traits: { workspaceContext: true, skillCarryForward: true },
|
||||
};
|
||||
|
||||
const builtinAgents: Record<string, BuiltinAgentDefinition> = {
|
||||
|
|
@ -71,15 +75,26 @@ export function builtinAgentIds(): string[] {
|
|||
return Object.keys(builtinAgents);
|
||||
}
|
||||
|
||||
// Trait lookup for assembly decisions. Unknown/user agents have no traits.
|
||||
export function hasWorkspaceContext(agentId: string | null | undefined): boolean {
|
||||
// Trait lookups for assembly decisions. Unknown/user agents have no traits.
|
||||
function hasTrait(
|
||||
agentId: string | null | undefined,
|
||||
trait: keyof AgentTraits,
|
||||
): boolean {
|
||||
return (
|
||||
agentId != null &&
|
||||
Object.hasOwn(builtinAgents, agentId) &&
|
||||
builtinAgents[agentId].traits?.workspaceContext === true
|
||||
builtinAgents[agentId].traits?.[trait] === true
|
||||
);
|
||||
}
|
||||
|
||||
export function hasWorkspaceContext(agentId: string | null | undefined): boolean {
|
||||
return hasTrait(agentId, "workspaceContext");
|
||||
}
|
||||
|
||||
export function carriesSkillsForward(agentId: string | null | undefined): boolean {
|
||||
return hasTrait(agentId, "skillCarryForward");
|
||||
}
|
||||
|
||||
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
||||
// Object.hasOwn: a plain lookup would traverse Object.prototype, so a
|
||||
// user agent named "constructor"/"toString" would hit an inherited
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
import { jsonSchema, ModelMessage } from "ai";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { jsonSchema } from "ai";
|
||||
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
||||
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessageContext } from "@x/shared/dist/message.js";
|
||||
import { AssistantContentPart, AssistantMessage, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessageContext } from "@x/shared/dist/message.js";
|
||||
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
|
||||
import { z } from "zod";
|
||||
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
|
||||
import { execTool } from "../application/lib/exec-tool.js";
|
||||
import { TOOL_ADDITIONS_KEY } from "../application/lib/tool-additions.js";
|
||||
import { AskHumanRequestEvent, RunEvent, ToolPermissionMetadata, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||
import { hasWorkspaceContext, loadAgent } from "./registry.js";
|
||||
import { composeSystemInstructions } from "./compose-instructions.js";
|
||||
import { convertFromMessages } from "./message-encoding.js";
|
||||
import { getToolPermissionMetadata } from "./permission-metadata.js";
|
||||
import { loadWorkspaceContext } from "./workspace-context.js";
|
||||
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
||||
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
|
||||
import { resolveFilePathForPermission } from "../filesystem/files.js";
|
||||
import { extractCommandNames } from "../application/lib/command-executor.js";
|
||||
import { type FileAccessGrant } from "../config/security.js";
|
||||
import { notifyIfEnabled } from "../application/notification/notifier.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
import { createLanguageModel } from "../models/models.js";
|
||||
|
|
@ -26,137 +25,12 @@ import { IBus } from "../application/lib/bus.js";
|
|||
import { IMessageQueue, type MiddlePaneContext } from "../application/lib/message-queue.js";
|
||||
import { IRunsRepo } from "../runs/repo.js";
|
||||
import { IRunsLock } from "../runs/lock.js";
|
||||
import { IAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { IAbortRegistry } from "../turns/abort-registry.js";
|
||||
import { PrefixLogger } from "@x/shared";
|
||||
import { captureLlmUsage } from "../analytics/usage.js";
|
||||
import { enterUseCase, withUseCase, type UseCase } from "../analytics/use_case.js";
|
||||
import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js";
|
||||
|
||||
type ToolPermissionMetadataValue = z.infer<typeof ToolPermissionMetadata>;
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function fileGrantCoversPath(grant: FileAccessGrant, operation: FileAccessOperation, resolvedPath: string): boolean {
|
||||
return grant.operation === operation && isPathInside(path.resolve(grant.pathPrefix), path.resolve(resolvedPath));
|
||||
}
|
||||
|
||||
function commonPathPrefix(paths: string[]): string {
|
||||
if (!paths.length) return path.resolve(WorkDir);
|
||||
const split = paths.map(p => path.resolve(p).split(path.sep).filter(Boolean));
|
||||
const first = split[0];
|
||||
const common: string[] = [];
|
||||
for (let i = 0; i < first.length; i++) {
|
||||
if (split.every(parts => parts[i] === first[i])) {
|
||||
common.push(first[i]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const prefix = `${path.sep}${common.join(path.sep)}`;
|
||||
return prefix === path.sep ? prefix : path.resolve(prefix);
|
||||
}
|
||||
|
||||
function grantPrefixForTool(toolName: string, resolvedPaths: string[]): string {
|
||||
if (toolName === 'file-list' || toolName === 'file-glob' || toolName === 'file-grep' || toolName === 'file-mkdir') {
|
||||
return commonPathPrefix(resolvedPaths);
|
||||
}
|
||||
const parentPaths = resolvedPaths.map(p => path.dirname(p));
|
||||
return commonPathPrefix(parentPaths);
|
||||
}
|
||||
|
||||
function filePermissionTargets(toolName: string, args: Record<string, unknown>): { operation: FileAccessOperation; paths: string[] } | null {
|
||||
const pathArg = typeof args.path === 'string' ? args.path : undefined;
|
||||
switch (toolName) {
|
||||
case 'file-readText':
|
||||
case 'parseFile':
|
||||
case 'LLMParse':
|
||||
case 'file-exists':
|
||||
case 'file-stat':
|
||||
return pathArg ? { operation: 'read', paths: [pathArg] } : null;
|
||||
case 'file-list':
|
||||
return pathArg ? { operation: 'list', paths: [pathArg || '.'] } : null;
|
||||
case 'file-glob':
|
||||
return { operation: 'search', paths: [typeof args.cwd === 'string' && args.cwd ? args.cwd : '.'] };
|
||||
case 'file-grep':
|
||||
return { operation: 'search', paths: [typeof args.searchPath === 'string' && args.searchPath ? args.searchPath : '.'] };
|
||||
case 'file-writeText':
|
||||
case 'file-editText':
|
||||
case 'file-mkdir':
|
||||
return pathArg ? { operation: 'write', paths: [pathArg] } : null;
|
||||
case 'file-copy':
|
||||
case 'file-rename': {
|
||||
const from = typeof args.from === 'string' ? args.from : undefined;
|
||||
const to = typeof args.to === 'string' ? args.to : undefined;
|
||||
return from && to ? { operation: 'write', paths: [from, to] } : null;
|
||||
}
|
||||
case 'file-remove':
|
||||
return pathArg ? { operation: 'delete', paths: [pathArg] } : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getToolPermissionMetadata(
|
||||
toolCall: z.infer<typeof ToolCallPart>,
|
||||
underlyingTool: z.infer<typeof ToolAttachment>,
|
||||
sessionAllowedCommands: Set<string>,
|
||||
sessionAllowedFileAccess: FileAccessGrant[],
|
||||
): Promise<ToolPermissionMetadataValue | null> {
|
||||
if (underlyingTool.type !== 'builtin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (underlyingTool.name === 'executeCommand') {
|
||||
const args = toolCall.arguments;
|
||||
if (!args || typeof args !== 'object' || !('command' in args)) {
|
||||
return null;
|
||||
}
|
||||
const command = String((args as { command: unknown }).command);
|
||||
if (!isBlocked(command, sessionAllowedCommands)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: 'command',
|
||||
commandNames: extractCommandNames(command),
|
||||
};
|
||||
}
|
||||
|
||||
const args = toolCall.arguments && typeof toolCall.arguments === 'object'
|
||||
? toolCall.arguments as Record<string, unknown>
|
||||
: {};
|
||||
const targets = filePermissionTargets(underlyingTool.name, args);
|
||||
if (!targets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedTargets = await Promise.all(targets.paths.map(p => resolveFilePathForPermission(p)));
|
||||
const outsideWorkspacePaths = resolvedTargets
|
||||
.filter(target => !target.isInsideWorkspace)
|
||||
.map(target => target.canonicalPath);
|
||||
if (!outsideWorkspacePaths.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistentGrants = getFileAccessAllowList();
|
||||
const allGrants = [...persistentGrants, ...sessionAllowedFileAccess];
|
||||
const uncovered = outsideWorkspacePaths.filter(resolvedPath =>
|
||||
!allGrants.some(grant => fileGrantCoversPath(grant, targets.operation, resolvedPath))
|
||||
);
|
||||
if (!uncovered.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'file',
|
||||
operation: targets.operation,
|
||||
paths: uncovered,
|
||||
pathPrefix: grantPrefixForTool(underlyingTool.name, uncovered),
|
||||
};
|
||||
}
|
||||
|
||||
function formatCurrentDateTime(now: Date): string {
|
||||
return now.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
|
|
@ -202,35 +76,6 @@ function buildUserMessageContext({
|
|||
};
|
||||
}
|
||||
|
||||
function formatUserMessageContextForLlm(userMessageContext: z.infer<typeof UserMessageContext>): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (userMessageContext.currentDateTime) {
|
||||
sections.push(`Current date and time: ${userMessageContext.currentDateTime}`);
|
||||
}
|
||||
|
||||
if (userMessageContext.middlePane) {
|
||||
if (userMessageContext.middlePane.kind === 'empty') {
|
||||
sections.push(`Middle pane:\nState: empty`);
|
||||
} else if (userMessageContext.middlePane.kind === 'note') {
|
||||
sections.push(`Middle pane:\nState: note\nPath: ${userMessageContext.middlePane.path}\n\nContent:\n\`\`\`\n${userMessageContext.middlePane.content}\n\`\`\``);
|
||||
} else {
|
||||
sections.push(`Middle pane:\nState: browser\nURL: ${userMessageContext.middlePane.url}\nTitle: ${userMessageContext.middlePane.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `# User Context
|
||||
${sections.join('\n\n')}
|
||||
|
||||
# User Message
|
||||
`;
|
||||
}
|
||||
|
||||
import { composeSystemInstructions } from "./compose-instructions.js";
|
||||
|
||||
export interface IAgentRuntime {
|
||||
trigger(runId: string): Promise<void>;
|
||||
|
|
@ -406,7 +251,7 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
}
|
||||
}
|
||||
|
||||
export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<Tool> {
|
||||
async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<Tool> {
|
||||
switch (t.type) {
|
||||
case "mcp":
|
||||
return tool({
|
||||
|
|
@ -449,38 +294,8 @@ export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<T
|
|||
}
|
||||
}
|
||||
|
||||
export class RunLogger {
|
||||
private logFile: string;
|
||||
private fileHandle: fs.WriteStream;
|
||||
|
||||
ensureRunsDir() {
|
||||
const runsDir = path.join(WorkDir, "runs");
|
||||
if (!fs.existsSync(runsDir)) {
|
||||
fs.mkdirSync(runsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
constructor(runId: string) {
|
||||
this.ensureRunsDir();
|
||||
this.logFile = path.join(WorkDir, "runs", `${runId}.jsonl`);
|
||||
this.fileHandle = fs.createWriteStream(this.logFile, {
|
||||
flags: "a",
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
log(event: z.infer<typeof RunEvent>) {
|
||||
if (event.type !== "llm-stream-event") {
|
||||
this.fileHandle.write(JSON.stringify(event) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.fileHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamStepMessageBuilder {
|
||||
class StreamStepMessageBuilder {
|
||||
private parts: z.infer<typeof AssistantContentPart>[] = [];
|
||||
private textBuffer: string = "";
|
||||
private reasoningBuffer: string = "";
|
||||
|
|
@ -569,156 +384,6 @@ function formatLlmStreamError(rawError: unknown): string {
|
|||
return lines.length ? lines.join("\n") : "Model stream error";
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
||||
const result: ModelMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
const { providerOptions } = msg;
|
||||
switch (msg.role) {
|
||||
case "assistant":
|
||||
if (typeof msg.content === 'string') {
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: msg.content,
|
||||
providerOptions,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: msg.content.map(part => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return part;
|
||||
case 'reasoning':
|
||||
return part;
|
||||
case 'tool-call':
|
||||
return {
|
||||
type: 'tool-call',
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
input: part.arguments,
|
||||
providerOptions: part.providerOptions,
|
||||
};
|
||||
}
|
||||
}),
|
||||
providerOptions,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "system":
|
||||
result.push({
|
||||
role: "system",
|
||||
content: msg.content,
|
||||
providerOptions,
|
||||
});
|
||||
break;
|
||||
case "user": {
|
||||
const userMessageContextPrefix = msg.userMessageContext ? formatUserMessageContextForLlm(msg.userMessageContext) : '';
|
||||
if (typeof msg.content === 'string') {
|
||||
// Legacy string — pass through unchanged
|
||||
result.push({
|
||||
role: "user",
|
||||
content: `${userMessageContextPrefix}${msg.content}`,
|
||||
providerOptions,
|
||||
});
|
||||
} else {
|
||||
// New content parts array — collapse text/attachments to text
|
||||
// for the LLM; inline image parts (video-mode webcam and
|
||||
// screen-share frames) are passed through as real multimodal
|
||||
// image parts, grouped under labeled text headers so the
|
||||
// model knows which images show the user vs their screen.
|
||||
const textSegments: string[] = userMessageContextPrefix ? [userMessageContextPrefix] : [];
|
||||
const attachmentLines: string[] = [];
|
||||
type EncodedImagePart = { type: "image"; image: string; mediaType: string };
|
||||
const cameraParts: EncodedImagePart[] = [];
|
||||
const screenParts: EncodedImagePart[] = [];
|
||||
const frameTimes: string[] = [];
|
||||
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "attachment") {
|
||||
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
|
||||
const lineStr = part.lineNumber ? ` (line ${part.lineNumber})` : '';
|
||||
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}${lineStr}`);
|
||||
} else if (part.type === "image") {
|
||||
const target = part.source === "screen" ? screenParts : cameraParts;
|
||||
target.push({ type: "image", image: part.data, mediaType: part.mediaType });
|
||||
if (part.capturedAt) frameTimes.push(part.capturedAt);
|
||||
} else {
|
||||
textSegments.push(part.text);
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentLines.length > 0) {
|
||||
if (userMessageContextPrefix) {
|
||||
textSegments.push("User has attached the following files:", ...attachmentLines, "");
|
||||
} else {
|
||||
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
|
||||
}
|
||||
}
|
||||
|
||||
const imageCount = cameraParts.length + screenParts.length;
|
||||
if (imageCount > 0) {
|
||||
const span = frameTimes.length >= 2
|
||||
? ` spanning ${frameTimes[0]} to ${frameTimes[frameTimes.length - 1]}`
|
||||
: frameTimes.length === 1
|
||||
? ` captured at ${frameTimes[0]}`
|
||||
: '';
|
||||
const kinds: string[] = [];
|
||||
if (cameraParts.length > 0) kinds.push(`${cameraParts.length} live webcam frame${cameraParts.length === 1 ? '' : 's'} of the user`);
|
||||
if (screenParts.length > 0) kinds.push(`${screenParts.length} frame${screenParts.length === 1 ? '' : 's'} of the user's shared screen`);
|
||||
textSegments.push(`[Video mode: ${kinds.join(' and ')} attached below, each group oldest to newest,${span ? span + ',' : ''} recorded while they composed this message.]`);
|
||||
const content: Array<{ type: "text"; text: string } | EncodedImagePart> = [
|
||||
{ type: "text", text: textSegments.join("\n") },
|
||||
];
|
||||
if (cameraParts.length > 0) {
|
||||
content.push({ type: "text", text: "Webcam frames (oldest to newest):" }, ...cameraParts);
|
||||
}
|
||||
if (screenParts.length > 0) {
|
||||
content.push({ type: "text", text: "Screen-share frames (oldest to newest):" }, ...screenParts);
|
||||
}
|
||||
result.push({
|
||||
role: "user",
|
||||
content,
|
||||
providerOptions,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
role: "user",
|
||||
content: textSegments.join("\n"),
|
||||
providerOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool":
|
||||
result.push({
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
output: {
|
||||
type: "text",
|
||||
value: msg.content,
|
||||
},
|
||||
},
|
||||
],
|
||||
providerOptions,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
// doing this because: https://github.com/OpenRouterTeam/ai-sdk-provider/issues/262
|
||||
return JSON.parse(JSON.stringify(result));
|
||||
}
|
||||
|
||||
async function buildTools(agent: z.infer<typeof Agent>): Promise<ToolSet> {
|
||||
const tools: ToolSet = {};
|
||||
for (const [name, tool] of Object.entries(agent.tools ?? {})) {
|
||||
|
|
@ -1582,7 +1247,3 @@ async function* streamLlm(
|
|||
}
|
||||
}
|
||||
}
|
||||
export const MappedToolCall = z.object({
|
||||
toolCall: ToolCallPart,
|
||||
agentTool: ToolAttachment,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
// Connection-state checks shared by skill availability (catalog visibility)
|
||||
// and copilot prompt composition (connection-specific blocks). One source of
|
||||
// truth per fact — the "is Slack connected" rule must never fork between the
|
||||
// catalog and the prompt. Repos resolve lazily so this module adds no static
|
||||
// DI edge; any failure reads as "not connected" (the historical default).
|
||||
import { lazyResolve } from "../../di/lazy-resolve.js";
|
||||
|
||||
export async function isComposioAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const { isConfigured } = await import("../../composio/client.js");
|
||||
return await isConfigured();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isCodeModeAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const repo = await lazyResolve<import("../../code-mode/repo.js").ICodeModeConfigRepo>("codeModeConfigRepo");
|
||||
return (await repo.getConfig()).enabled;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isSlackAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const repo = await lazyResolve<import("../../slack/repo.js").ISlackConfigRepo>("slackConfigRepo");
|
||||
const config = await repo.getConfig();
|
||||
return config.enabled && config.workspaces.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isGoogleConnected(): Promise<boolean> {
|
||||
try {
|
||||
const repo = await lazyResolve<import("../../auth/repo.js").IOAuthRepo>("oauthRepo");
|
||||
const connection = await repo.read("google");
|
||||
return !!connection.tokens;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
import { skillCatalog, buildAvailableSkillCatalog } from "./skills/index.js";
|
||||
import { buildAvailableSkillCatalog } from "./skills/index.js";
|
||||
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
|
||||
import {
|
||||
isCodeModeAvailable,
|
||||
isComposioAvailable,
|
||||
isGoogleConnected,
|
||||
isSlackAvailable,
|
||||
} from "./connections.js";
|
||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
||||
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
|
||||
import container from "../../di/container.js";
|
||||
import type { ICodeModeConfigRepo } from "../../code-mode/repo.js";
|
||||
import type { ISlackConfigRepo } from "../../slack/repo.js";
|
||||
import type { IOAuthRepo } from "../../auth/repo.js";
|
||||
import { knowledgeSourcesRepo } from "../../knowledge/sources/repo.js";
|
||||
|
||||
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||
|
|
@ -356,7 +358,6 @@ Never output raw file paths in plain text when they could be wrapped in a filepa
|
|||
}
|
||||
|
||||
/** Keep backward-compatible export for any external consumers */
|
||||
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
|
||||
|
||||
let cachedInstructions: string | null = null;
|
||||
|
||||
|
|
@ -366,31 +367,16 @@ export function invalidateCopilotInstructionsCache(): void {
|
|||
|
||||
export async function buildCopilotInstructions(): Promise<string> {
|
||||
if (cachedInstructions !== null) return cachedInstructions;
|
||||
const composioEnabled = await isComposioConfigured();
|
||||
let codeModeEnabled = false;
|
||||
try {
|
||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||
codeModeEnabled = (await repo.getConfig()).enabled;
|
||||
} catch {
|
||||
// repo unavailable — default to disabled
|
||||
}
|
||||
let slackConnected = false;
|
||||
// Connection facts come from the shared checks in connections.ts — the
|
||||
// same source the skill catalog's availability gating uses.
|
||||
const [composioEnabled, codeModeEnabled, slackConnected, googleConnected] =
|
||||
await Promise.all([
|
||||
isComposioAvailable(),
|
||||
isCodeModeAvailable(),
|
||||
isSlackAvailable(),
|
||||
isGoogleConnected(),
|
||||
]);
|
||||
let slackChannelsHint = '';
|
||||
try {
|
||||
const slackRepo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
|
||||
const slackConfig = await slackRepo.getConfig();
|
||||
slackConnected = slackConfig.enabled && slackConfig.workspaces.length > 0;
|
||||
} catch {
|
||||
// repo unavailable — default to not connected
|
||||
}
|
||||
let googleConnected = false;
|
||||
try {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const googleConnection = await oauthRepo.read('google');
|
||||
googleConnected = !!googleConnection.tokens;
|
||||
} catch {
|
||||
// repo unavailable — default to not connected
|
||||
}
|
||||
if (slackConnected) {
|
||||
try {
|
||||
// Surface the channels the user selected for sync so the Copilot
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import {
|
|||
type CapabilityDefinition,
|
||||
type ModelCapability,
|
||||
} from "../capabilities/types.js";
|
||||
import {
|
||||
isCodeModeAvailable,
|
||||
isComposioAvailable,
|
||||
isSlackAvailable,
|
||||
} from "../connections.js";
|
||||
import { loadDiskSkills } from "./disk-loader.js";
|
||||
import builtinToolsSkill from "./builtin-tools/skill.js";
|
||||
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
|
||||
|
|
@ -239,39 +244,8 @@ function getSkillEntries(): SkillEntry[] {
|
|||
}
|
||||
|
||||
// ---- Availability checks (catalog visibility) ----
|
||||
// Connection-state gates for the catalog, evaluated per build. Repos resolve
|
||||
// lazily so this module keeps no static edge into the DI graph; any failure
|
||||
// reads as "not available" (the historical default).
|
||||
|
||||
async function isComposioAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const { isConfigured } = await import("../../../composio/client.js");
|
||||
return await isConfigured();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isCodeModeAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const { lazyResolve } = await import("../../../di/lazy-resolve.js");
|
||||
const repo = await lazyResolve<import("../../../code-mode/repo.js").ICodeModeConfigRepo>("codeModeConfigRepo");
|
||||
return (await repo.getConfig()).enabled;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isSlackAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const { lazyResolve } = await import("../../../di/lazy-resolve.js");
|
||||
const repo = await lazyResolve<import("../../../slack/repo.js").ISlackConfigRepo>("slackConfigRepo");
|
||||
const config = await repo.getConfig();
|
||||
return config.enabled && config.workspaces.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// One source of truth for connection state: ../connections.ts (shared with
|
||||
// the copilot prompt's connection blocks in instructions.ts).
|
||||
|
||||
// Pure availability filter over an explicit entry list (exported for tests):
|
||||
// entries without an availability check are kept; checks are evaluated
|
||||
|
|
@ -427,10 +401,6 @@ export function refreshDiskSkills(): number {
|
|||
// Initial disk load at module init.
|
||||
refreshDiskSkills();
|
||||
|
||||
// Snapshot of the catalog at module load, kept for backward-compatible
|
||||
// consumers (e.g. the legacy CopilotInstructions export). Runtime consumers
|
||||
// should call buildSkillCatalog() to get the live set.
|
||||
export const skillCatalog = buildSkillCatalog();
|
||||
|
||||
export function resolveSkill(identifier: string): ResolvedSkill | null {
|
||||
const normalized = normalizeIdentifier(identifier);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import * as os from "os";
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodeRunEvent } from "@x/shared/dist/code-mode.js";
|
||||
import container from "../../di/container.js";
|
||||
import { InMemoryAbortRegistry } from "../../runs/abort-registry.js";
|
||||
import { InMemoryAbortRegistry } from "../../turns/abort-registry.js";
|
||||
import { BuiltinTools, coalesceCodeRunEvents } from "./builtin-tools.js";
|
||||
import type { ToolContext } from "./exec-tool.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { RunEvent } from "@x/shared/dist/runs.js";
|
|||
import { z } from "zod";
|
||||
import { BuiltinTools } from "./builtin-tools.js";
|
||||
import { executeTool } from "../../mcp/mcp.js";
|
||||
import { IAbortRegistry } from "../../runs/abort-registry.js";
|
||||
import { IAbortRegistry } from "../../turns/abort-registry.js";
|
||||
|
||||
/**
|
||||
* Context passed to every tool execution, providing abort signal and run metadata.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { assistantText, lastAssistantText } from "../agents/headless.js";
|
|||
import { TurnInputError } from "../turns/api.js";
|
||||
import { ASK_HUMAN_TOOL } from "../turns/bridges/real-agent-resolver.js";
|
||||
import { TurnNotSettledError, type ISessions } from "../sessions/api.js";
|
||||
import type { TurnEventHub } from "../turns/event-hub.js";
|
||||
import type { ITurnEventBus } from "../turns/event-hub.js";
|
||||
|
||||
// Transport-agnostic command layer: inbound texts from a messaging channel
|
||||
// are parsed into commands (list / resume / new / stop / status) or forwarded
|
||||
|
|
@ -136,7 +136,7 @@ export class ChannelBridge {
|
|||
constructor(
|
||||
private readonly deps: {
|
||||
sessions: ISessions;
|
||||
turnEventBus: TurnEventHub;
|
||||
turnEventBus: ITurnEventBus;
|
||||
listModels: () => Promise<ModelChoice[]>;
|
||||
},
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { ChannelsConfig, ChannelsStatus } from "@x/shared/dist/channels.js"
|
|||
import container from "../di/container.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import type { ISessions } from "../sessions/api.js";
|
||||
import type { TurnEventHub } from "../turns/event-hub.js";
|
||||
import type { ITurnEventBus } from "../turns/event-hub.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import { listGatewayModels } from "../models/gateway.js";
|
||||
import { listOnboardingModels } from "../models/models-dev.js";
|
||||
|
|
@ -98,7 +98,7 @@ function ensureBridge(): ChannelBridge {
|
|||
if (!bridge) {
|
||||
bridge = new ChannelBridge({
|
||||
sessions: container.resolve<ISessions>("sessions"),
|
||||
turnEventBus: container.resolve<TurnEventHub>("turnEventBus"),
|
||||
turnEventBus: container.resolve<ITurnEventBus>("turnEventBus"),
|
||||
listModels: listBridgeModels,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type { IRunsRepo } from '../../runs/repo.js';
|
|||
import type { IRunsLock } from '../../runs/lock.js';
|
||||
import type { IBus } from '../../application/lib/bus.js';
|
||||
import type { IMonotonicallyIncreasingIdGenerator } from '../../application/lib/id-gen.js';
|
||||
import type { IAbortRegistry } from '../../runs/abort-registry.js';
|
||||
import type { IAbortRegistry } from '../../turns/abort-registry.js';
|
||||
import type { CodeModeManager } from '../acp/manager.js';
|
||||
import type { CodePermissionRegistry } from '../acp/permission-registry.js';
|
||||
import type { ICodeSessionsRepo } from './repo.js';
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
|
|||
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
|
||||
import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js";
|
||||
import { FSCodeModeConfigRepo, ICodeModeConfigRepo } from "../code-mode/repo.js";
|
||||
import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { IAbortRegistry, InMemoryAbortRegistry } from "../turns/abort-registry.js";
|
||||
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
||||
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
||||
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { IRunsRepo } from "./repo.js";
|
|||
import { ICodeSessionsRepo } from "../code-mode/sessions/repo.js";
|
||||
import { IAgentRuntime } from "../agents/runtime.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
import { IAbortRegistry } from "./abort-registry.js";
|
||||
import { IAbortRegistry } from "../turns/abort-registry.js";
|
||||
import { IRunsLock } from "./lock.js";
|
||||
import { forceCloseAllMcpClients } from "../mcp/mcp.js";
|
||||
import { extractCommandNames } from "../application/lib/command-executor.js";
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
loadUserWorkDir,
|
||||
loadWorkspaceContext,
|
||||
} from "../../agents/workspace-context.js";
|
||||
import { hasWorkspaceContext, loadAgent } from "../../agents/registry.js";
|
||||
import { carriesSkillsForward, loadAgent } from "../../agents/registry.js";
|
||||
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||
import { skillToolNames } from "../../application/assistant/skills/index.js";
|
||||
import { ModeFlags } from "../../application/assistant/capabilities/types.js";
|
||||
|
|
@ -133,7 +133,6 @@ export class RealAgentResolver {
|
|||
loadNotes: this.loadNotes,
|
||||
loadWorkDir: this.loadWorkDir,
|
||||
});
|
||||
const copilotContext = hasWorkspaceContext(requested.agentId);
|
||||
const systemPrompt = composeSystemInstructions({
|
||||
instructions: agent.instructions,
|
||||
...workspace,
|
||||
|
|
@ -143,7 +142,7 @@ export class RealAgentResolver {
|
|||
const tools = await this.resolveTools(agent, {
|
||||
subagent: subagent ?? false,
|
||||
});
|
||||
if (copilotContext && activeSkills?.length) {
|
||||
if (carriesSkillsForward(requested.agentId) && activeSkills?.length) {
|
||||
await this.appendSkillTools(tools, activeSkills);
|
||||
}
|
||||
return ResolvedAgent.parse({
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type { z } from "zod";
|
|||
import type { LlmProvider } from "@x/shared/dist/models.js";
|
||||
import type { AssistantContentPart } from "@x/shared/dist/message.js";
|
||||
import type { JsonValue, ModelDescriptor, TurnUsage } from "@x/shared/dist/turns.js";
|
||||
import { convertFromMessages } from "../../agents/runtime.js";
|
||||
import { convertFromMessages } from "../../agents/message-encoding.js";
|
||||
import { resolveProviderConfig } from "../../models/defaults.js";
|
||||
import { createProvider } from "../../models/models.js";
|
||||
import { isReasoningModel } from "../../models/models-dev.js";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { JsonValue } from "@x/shared/dist/turns.js";
|
||||
import { getToolPermissionMetadata } from "../../agents/runtime.js";
|
||||
import { getToolPermissionMetadata } from "../../agents/permission-metadata.js";
|
||||
import type {
|
||||
IPermissionChecker,
|
||||
PermissionCheckAllowed,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||
import { convertFromMessages } from "../../agents/runtime.js";
|
||||
import { convertFromMessages } from "../../agents/message-encoding.js";
|
||||
import type { UseCase } from "../../analytics/use_case.js";
|
||||
import { classifyToolPermissions } from "../../security/auto-permission-classifier.js";
|
||||
import type {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { z } from "zod";
|
|||
import type { ToolDescriptor } from "@x/shared/dist/turns.js";
|
||||
import type { execTool } from "../../application/lib/exec-tool.js";
|
||||
import type { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||
import type { IAbortRegistry } from "../../runs/abort-registry.js";
|
||||
import type { IAbortRegistry } from "../abort-registry.js";
|
||||
import { TurnDependencyError } from "../api.js";
|
||||
import type { SyncRuntimeTool, ToolExecutionContext } from "../tool-registry.js";
|
||||
import { RealToolRegistry } from "./real-tool-registry.js";
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { TOOL_ADDITIONS_KEY } from "../../application/lib/tool-additions.js";
|
|||
import {
|
||||
type IAbortRegistry,
|
||||
InMemoryAbortRegistry,
|
||||
} from "../../runs/abort-registry.js";
|
||||
} from "../abort-registry.js";
|
||||
import { TurnDependencyError } from "../api.js";
|
||||
import type {
|
||||
IToolRegistry,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import type { TurnBusEvent } from "@x/shared/dist/turns.js";
|
|||
// listeners accurately reflects that no execution is known active.
|
||||
export interface ITurnEventBus {
|
||||
publish(event: TurnBusEvent): void;
|
||||
// Tail one turn's events / tail everything (the IPC bridge, watchers).
|
||||
subscribe(turnId: string, listener: (event: TurnBusEvent) => void): () => void;
|
||||
subscribeAll(listener: (event: TurnBusEvent) => void): () => void;
|
||||
}
|
||||
|
||||
type Listener = (event: TurnBusEvent) => void;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
} from "@x/shared/dist/turns.js";
|
||||
import { reduceSession } from "@x/shared/dist/sessions.js";
|
||||
import type { z } from "zod";
|
||||
import { convertFromMessages } from "../agents/runtime.js";
|
||||
import { convertFromMessages } from "../agents/message-encoding.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { FSSessionRepo } from "../sessions/fs-repo.js";
|
||||
import { composeModelRequest } from "./compose-model-request.js";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue