689 lines
23 KiB
TypeScript
689 lines
23 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import { z } from "zod";
|
|
|
|
import type { AgentScope } from "../domain/scopes.js";
|
|
import { structuredBlocksSchema } from "../domain/structured-blocks.js";
|
|
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
|
|
import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
|
|
import type { TaskerClient } from "../tasker/client.js";
|
|
|
|
type JsonSchema = Record<string, unknown>;
|
|
|
|
export type McpToolRuntimeDefinition = {
|
|
name: string;
|
|
title: string;
|
|
description: string;
|
|
requiredScopes: AgentScope[];
|
|
inputSchema: JsonSchema;
|
|
annotations?: Record<string, unknown>;
|
|
};
|
|
|
|
export type McpToolResult = {
|
|
content: Array<{
|
|
type: "text";
|
|
text: string;
|
|
}>;
|
|
structuredContent?: unknown;
|
|
isError?: boolean;
|
|
};
|
|
|
|
type ExecuteToolDeps = {
|
|
agentsRepository?: AgentsRepository | null;
|
|
taskerClient: TaskerClient;
|
|
};
|
|
|
|
type ExecuteToolOptions = {
|
|
source?: "mcp" | "rest";
|
|
idempotencyKey?: string | null;
|
|
};
|
|
|
|
const emptyInputSchema = {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
};
|
|
|
|
const projectInputSchema = {
|
|
type: "object",
|
|
properties: {
|
|
project_id: { type: "string" },
|
|
workspace_slug: { type: "string" },
|
|
},
|
|
required: ["project_id"],
|
|
additionalProperties: false,
|
|
};
|
|
|
|
const projectAndIssueInputSchema = {
|
|
type: "object",
|
|
properties: {
|
|
issue_id: { type: "string" },
|
|
project_id: { type: "string" },
|
|
workspace_slug: { type: "string" },
|
|
},
|
|
required: ["issue_id", "project_id"],
|
|
additionalProperties: false,
|
|
};
|
|
|
|
const structuredBlocksJsonSchema = {
|
|
type: "array",
|
|
items: {
|
|
oneOf: [
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "string" },
|
|
type: { const: "text" },
|
|
title: {
|
|
type: "string",
|
|
description: "Visible block title. Put headings here, not inside body markdown.",
|
|
},
|
|
body: {
|
|
type: "string",
|
|
description: "Plain block body without a leading markdown heading.",
|
|
},
|
|
},
|
|
required: ["id", "type", "title", "body"],
|
|
additionalProperties: false,
|
|
},
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "string" },
|
|
type: { const: "checker" },
|
|
title: {
|
|
type: "string",
|
|
description: "Visible checklist title. Put headings here, not inside item text.",
|
|
},
|
|
items: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "string" },
|
|
text: { type: "string" },
|
|
checked: { type: "boolean" },
|
|
},
|
|
required: ["id", "text"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
required: ["id", "type", "title", "items"],
|
|
additionalProperties: false,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
|
{
|
|
name: "tasker_get_agent_instructions",
|
|
title: "Get Agent Instructions",
|
|
description: "Return effective NODE.DC Tasker card-writing rules, grants, scopes, and mode expectations.",
|
|
requiredScopes: ["workspace:read"],
|
|
inputSchema: emptyInputSchema,
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "tasker_list_projects",
|
|
title: "List Granted Projects",
|
|
description: "List Tasker projects granted to the current agent.",
|
|
requiredScopes: ["project:read"],
|
|
inputSchema: emptyInputSchema,
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "tasker_get_project_context",
|
|
title: "Get Project Context",
|
|
description: "Return states, labels, members, and card-writing context for one granted project.",
|
|
requiredScopes: ["project:read"],
|
|
inputSchema: projectInputSchema,
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "tasker_search_issues",
|
|
title: "Search Issues",
|
|
description: "Search work items inside one granted Tasker project.",
|
|
requiredScopes: ["issue:read"],
|
|
inputSchema: {
|
|
...projectInputSchema,
|
|
properties: {
|
|
...projectInputSchema.properties,
|
|
query: { type: "string" },
|
|
},
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "tasker_create_issue",
|
|
title: "Create Issue",
|
|
description: "Create a Tasker card with optional NODE.DC structured text/checker blocks.",
|
|
requiredScopes: ["issue:create"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
project_id: { type: "string" },
|
|
workspace_slug: { type: "string" },
|
|
title: { type: "string" },
|
|
description: { type: "string" },
|
|
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
|
|
structured_blocks: structuredBlocksJsonSchema,
|
|
idempotency_key: { type: "string" },
|
|
},
|
|
required: ["project_id", "title"],
|
|
additionalProperties: false,
|
|
},
|
|
annotations: { destructiveHint: false, idempotentHint: false },
|
|
},
|
|
{
|
|
name: "tasker_update_issue",
|
|
title: "Update Issue",
|
|
description: "Patch allowed issue fields without delete, archive, or project transfer.",
|
|
requiredScopes: ["issue:update"],
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
issue_id: { type: "string" },
|
|
project_id: { type: "string" },
|
|
workspace_slug: { type: "string" },
|
|
title: { type: "string" },
|
|
description: { type: "string" },
|
|
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
|
|
structured_blocks: structuredBlocksJsonSchema,
|
|
idempotency_key: { type: "string" },
|
|
},
|
|
required: ["issue_id", "project_id"],
|
|
additionalProperties: false,
|
|
},
|
|
annotations: { destructiveHint: false, idempotentHint: false },
|
|
},
|
|
{
|
|
name: "tasker_update_structured_blocks",
|
|
title: "Update Structured Blocks",
|
|
description: "Replace NODE.DC structured text/checker blocks in an issue detail layout.",
|
|
requiredScopes: ["issue:update", "issue:structured_blocks:write"],
|
|
inputSchema: {
|
|
...projectAndIssueInputSchema,
|
|
properties: {
|
|
...projectAndIssueInputSchema.properties,
|
|
structured_blocks: structuredBlocksJsonSchema,
|
|
idempotency_key: { type: "string" },
|
|
},
|
|
required: ["issue_id", "project_id", "structured_blocks"],
|
|
},
|
|
annotations: { destructiveHint: false, idempotentHint: false },
|
|
},
|
|
{
|
|
name: "tasker_move_issue",
|
|
title: "Move Issue",
|
|
description: "Move an issue to an existing state in the same granted project.",
|
|
requiredScopes: ["issue:move"],
|
|
inputSchema: {
|
|
...projectAndIssueInputSchema,
|
|
properties: {
|
|
...projectAndIssueInputSchema.properties,
|
|
state_id: { type: "string" },
|
|
idempotency_key: { type: "string" },
|
|
},
|
|
required: ["issue_id", "project_id", "state_id"],
|
|
},
|
|
annotations: { destructiveHint: false, idempotentHint: false },
|
|
},
|
|
{
|
|
name: "tasker_append_comment",
|
|
title: "Append Comment",
|
|
description: "Append a comment to a granted issue.",
|
|
requiredScopes: ["issue:comment"],
|
|
inputSchema: {
|
|
...projectAndIssueInputSchema,
|
|
properties: {
|
|
...projectAndIssueInputSchema.properties,
|
|
body: { type: "string" },
|
|
idempotency_key: { type: "string" },
|
|
},
|
|
required: ["issue_id", "project_id", "body"],
|
|
},
|
|
annotations: { destructiveHint: false, idempotentHint: false },
|
|
},
|
|
{
|
|
name: "tasker_ensure_labels",
|
|
title: "Ensure Project Labels",
|
|
description: "Create missing labels in a granted project and return label ids for subsequent issue labeling.",
|
|
requiredScopes: ["issue:label"],
|
|
inputSchema: {
|
|
...projectInputSchema,
|
|
properties: {
|
|
...projectInputSchema.properties,
|
|
labels: {
|
|
type: "array",
|
|
minItems: 1,
|
|
maxItems: 50,
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
color: {
|
|
type: "string",
|
|
description: "Optional hex color in #RRGGBB format.",
|
|
},
|
|
},
|
|
required: ["name"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
idempotency_key: { type: "string" },
|
|
},
|
|
required: ["project_id", "labels"],
|
|
},
|
|
annotations: { destructiveHint: false, idempotentHint: true },
|
|
},
|
|
{
|
|
name: "tasker_set_issue_labels",
|
|
title: "Set Issue Labels",
|
|
description: "Replace issue labels with existing or ensured labels from the granted project.",
|
|
requiredScopes: ["issue:label"],
|
|
inputSchema: {
|
|
...projectAndIssueInputSchema,
|
|
properties: {
|
|
...projectAndIssueInputSchema.properties,
|
|
label_ids: { type: "array", items: { type: "string" } },
|
|
idempotency_key: { type: "string" },
|
|
},
|
|
required: ["issue_id", "project_id", "label_ids"],
|
|
},
|
|
annotations: { destructiveHint: false, idempotentHint: true },
|
|
},
|
|
{
|
|
name: "tasker_assign_issue",
|
|
title: "Assign Issue",
|
|
description: "Replace issue assignees with existing members of the granted project.",
|
|
requiredScopes: ["issue:assign"],
|
|
inputSchema: {
|
|
...projectAndIssueInputSchema,
|
|
properties: {
|
|
...projectAndIssueInputSchema.properties,
|
|
member_ids: { type: "array", items: { type: "string" } },
|
|
idempotency_key: { type: "string" },
|
|
},
|
|
required: ["issue_id", "project_id", "member_ids"],
|
|
},
|
|
annotations: { destructiveHint: false, idempotentHint: true },
|
|
},
|
|
];
|
|
|
|
const emptyArgsSchema = z.object({}).default({});
|
|
const projectArgsSchema = z.object({
|
|
project_id: z.string().min(1),
|
|
workspace_slug: z.string().min(1).nullish(),
|
|
});
|
|
const searchIssuesArgsSchema = projectArgsSchema.extend({
|
|
query: z.string().min(1).optional(),
|
|
});
|
|
const prioritySchema = z.enum(["none", "low", "medium", "high", "urgent"]);
|
|
const createIssueArgsSchema = z.object({
|
|
project_id: z.string().min(1),
|
|
workspace_slug: z.string().min(1).nullish(),
|
|
title: z.string().min(1).max(500),
|
|
description: z.string().max(20000).optional(),
|
|
priority: prioritySchema.optional(),
|
|
structured_blocks: structuredBlocksSchema.optional(),
|
|
idempotency_key: z.string().optional(),
|
|
});
|
|
const issueArgsSchema = z.object({
|
|
issue_id: z.string().min(1),
|
|
project_id: z.string().min(1),
|
|
workspace_slug: z.string().min(1).nullish(),
|
|
});
|
|
const updateIssueArgsSchema = issueArgsSchema.extend({
|
|
title: z.string().min(1).max(500).optional(),
|
|
description: z.string().max(20000).optional(),
|
|
priority: prioritySchema.optional(),
|
|
structured_blocks: structuredBlocksSchema.optional(),
|
|
});
|
|
const structuredBlocksArgsSchema = issueArgsSchema.extend({
|
|
structured_blocks: structuredBlocksSchema,
|
|
});
|
|
const moveIssueArgsSchema = issueArgsSchema.extend({
|
|
state_id: z.string().min(1),
|
|
});
|
|
const commentArgsSchema = issueArgsSchema.extend({
|
|
body: z.string().min(1).max(20000),
|
|
});
|
|
const ensureLabelsArgsSchema = projectArgsSchema.extend({
|
|
labels: z
|
|
.array(
|
|
z.object({
|
|
name: z.string().min(1).max(255),
|
|
color: z
|
|
.string()
|
|
.regex(/^#[0-9a-fA-F]{6}$/)
|
|
.optional(),
|
|
})
|
|
)
|
|
.min(1)
|
|
.max(50),
|
|
});
|
|
const labelsArgsSchema = issueArgsSchema.extend({
|
|
label_ids: z.array(z.string().min(1)).default([]),
|
|
});
|
|
const assigneesArgsSchema = issueArgsSchema.extend({
|
|
member_ids: z.array(z.string().min(1)).default([]),
|
|
});
|
|
|
|
export function getToolsForSession(session: AgentSessionRecord): McpToolRuntimeDefinition[] {
|
|
return mcpRuntimeTools.filter((tool) => tool.requiredScopes.every((scope) => hasScope(session, scope)));
|
|
}
|
|
|
|
export async function executeMcpTool(
|
|
session: AgentSessionRecord,
|
|
name: string,
|
|
rawArguments: unknown,
|
|
deps: ExecuteToolDeps,
|
|
options: ExecuteToolOptions = {}
|
|
): Promise<McpToolResult> {
|
|
const tool = mcpRuntimeTools.find((candidate) => candidate.name === name);
|
|
|
|
if (!tool) {
|
|
throw new Error(`Unknown MCP tool: ${name}`);
|
|
}
|
|
|
|
const { args, idempotencyKey } = prepareToolArguments(rawArguments, options.idempotencyKey);
|
|
const isWriteTool = tool.annotations?.readOnlyHint !== true;
|
|
|
|
if (!isWriteTool) {
|
|
return executeMcpToolOnce(session, name, args, deps);
|
|
}
|
|
|
|
if (!deps.agentsRepository) {
|
|
throw new ToolExecutionInputError("idempotency_unavailable", "Agent Gateway persistence is required for write tools.", 503);
|
|
}
|
|
|
|
if (!idempotencyKey) {
|
|
throw new ToolExecutionInputError("idempotency_key_required", "Write tools require an idempotency key.", 400);
|
|
}
|
|
|
|
const requestHash = hashToolRequest(name, args);
|
|
const claim = await deps.agentsRepository.claimIdempotencyKey(session.agent.id, idempotencyKey, requestHash);
|
|
|
|
if (claim.status === "replay") {
|
|
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.replayed", session.agent.ownerUserId, {
|
|
source: options.source ?? "mcp",
|
|
toolName: name,
|
|
idempotencyKey,
|
|
});
|
|
return claim.responseBody as McpToolResult;
|
|
}
|
|
|
|
if (claim.status === "conflict") {
|
|
throw new ToolExecutionInputError("idempotency_key_conflict", "Idempotency key was already used with different arguments.", 409);
|
|
}
|
|
|
|
if (claim.status === "in_progress") {
|
|
throw new ToolExecutionInputError("idempotency_key_in_progress", "Idempotency key is currently processing.", 409, {
|
|
lockedUntil: claim.lockedUntil,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await executeMcpToolOnce(session, name, args, deps);
|
|
await deps.agentsRepository.completeIdempotencyKey(session.agent.id, idempotencyKey, result);
|
|
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.executed", session.agent.ownerUserId, {
|
|
source: options.source ?? "mcp",
|
|
toolName: name,
|
|
idempotencyKey,
|
|
arguments: summarizeToolArguments(args),
|
|
});
|
|
return result;
|
|
} catch (error) {
|
|
await deps.agentsRepository.releaseIdempotencyKey(session.agent.id, idempotencyKey);
|
|
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.failed", session.agent.ownerUserId, {
|
|
source: options.source ?? "mcp",
|
|
toolName: name,
|
|
idempotencyKey,
|
|
error: error instanceof Error ? error.name : "unknown_error",
|
|
message: error instanceof Error ? error.message : "Unknown tool execution error.",
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function executeMcpToolOnce(
|
|
session: AgentSessionRecord,
|
|
name: string,
|
|
args: unknown,
|
|
deps: ExecuteToolDeps
|
|
): Promise<McpToolResult> {
|
|
|
|
switch (name) {
|
|
case "tasker_get_agent_instructions":
|
|
emptyArgsSchema.parse(args);
|
|
requireScope(session, "workspace:read");
|
|
return asToolResult(buildAgentInstructions(session));
|
|
case "tasker_list_projects":
|
|
emptyArgsSchema.parse(args);
|
|
requireScope(session, "project:read");
|
|
return asToolResult(await deps.taskerClient.listGrantedProjects(session));
|
|
case "tasker_get_project_context": {
|
|
const input = projectArgsSchema.parse(args);
|
|
requireScope(session, "project:read");
|
|
requireProjectGrant(session, { projectId: input.project_id, workspaceSlug: input.workspace_slug });
|
|
return asToolResult(await deps.taskerClient.getProjectContext(session, input.project_id, input.workspace_slug));
|
|
}
|
|
case "tasker_search_issues": {
|
|
const input = searchIssuesArgsSchema.parse(args);
|
|
requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug);
|
|
return asToolResult(await deps.taskerClient.listIssues(session, input.project_id, input.workspace_slug, input.query));
|
|
}
|
|
case "tasker_create_issue": {
|
|
const input = createIssueArgsSchema.parse(args);
|
|
requireToolAccess(session, "issue:create", input.project_id, input.workspace_slug);
|
|
return asToolResult(await deps.taskerClient.createIssue(session, input));
|
|
}
|
|
case "tasker_update_issue": {
|
|
const input = updateIssueArgsSchema.parse(args);
|
|
if (input.structured_blocks) {
|
|
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
|
|
} else {
|
|
requireToolAccess(session, "issue:update", input.project_id, input.workspace_slug);
|
|
}
|
|
return asToolResult(await deps.taskerClient.updateIssue(session, input.issue_id, input));
|
|
}
|
|
case "tasker_update_structured_blocks": {
|
|
const input = structuredBlocksArgsSchema.parse(args);
|
|
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
|
|
return asToolResult(
|
|
await deps.taskerClient.updateIssue(session, input.issue_id, {
|
|
project_id: input.project_id,
|
|
workspace_slug: input.workspace_slug,
|
|
structured_blocks: input.structured_blocks,
|
|
})
|
|
);
|
|
}
|
|
case "tasker_move_issue": {
|
|
const input = moveIssueArgsSchema.parse(args);
|
|
requireToolAccess(session, "issue:move", input.project_id, input.workspace_slug);
|
|
return asToolResult(await deps.taskerClient.moveIssue(session, input.issue_id, input));
|
|
}
|
|
case "tasker_append_comment": {
|
|
const input = commentArgsSchema.parse(args);
|
|
requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug);
|
|
return asToolResult(await deps.taskerClient.appendComment(session, input.issue_id, input));
|
|
}
|
|
case "tasker_ensure_labels": {
|
|
const input = ensureLabelsArgsSchema.parse(args);
|
|
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
|
|
return asToolResult(await deps.taskerClient.ensureLabels(session, input));
|
|
}
|
|
case "tasker_set_issue_labels": {
|
|
const input = labelsArgsSchema.parse(args);
|
|
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
|
|
return asToolResult(await deps.taskerClient.setLabels(session, input.issue_id, input));
|
|
}
|
|
case "tasker_assign_issue": {
|
|
const input = assigneesArgsSchema.parse(args);
|
|
requireToolAccess(session, "issue:assign", input.project_id, input.workspace_slug);
|
|
return asToolResult(await deps.taskerClient.assignIssue(session, input.issue_id, input));
|
|
}
|
|
default:
|
|
throw new Error(`Unknown MCP tool: ${name}`);
|
|
}
|
|
}
|
|
|
|
function hasScope(session: AgentSessionRecord, scope: AgentScope): boolean {
|
|
return session.grants.some((grant) => grant.scopes.includes(scope));
|
|
}
|
|
|
|
function requireToolAccess(session: AgentSessionRecord, scope: AgentScope, projectId: string, workspaceSlug?: string | null): void {
|
|
requireProjectScopes(session, projectId, workspaceSlug, [scope]);
|
|
}
|
|
|
|
function requireProjectScopes(
|
|
session: AgentSessionRecord,
|
|
projectId: string,
|
|
workspaceSlug: string | null | undefined,
|
|
scopes: AgentScope[]
|
|
): void {
|
|
for (const scope of scopes) {
|
|
requireScope(session, scope);
|
|
}
|
|
|
|
const grant = requireProjectGrant(session, { projectId, workspaceSlug });
|
|
|
|
for (const scope of scopes) {
|
|
if (!grant.scopes.includes(scope)) {
|
|
throw new ForbiddenError(`Grant for project does not include required scope: ${scope}.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function asToolResult(payload: unknown): McpToolResult {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(payload, null, 2),
|
|
},
|
|
],
|
|
structuredContent: payload,
|
|
isError: false,
|
|
};
|
|
}
|
|
|
|
export class ToolExecutionInputError extends Error {
|
|
constructor(
|
|
readonly code: string,
|
|
message: string,
|
|
readonly httpStatus: number,
|
|
readonly details?: Record<string, unknown>
|
|
) {
|
|
super(message);
|
|
this.name = "ToolExecutionInputError";
|
|
}
|
|
}
|
|
|
|
function prepareToolArguments(rawArguments: unknown, headerIdempotencyKey?: string | null): { args: unknown; idempotencyKey: string | null } {
|
|
if (!isPlainRecord(rawArguments)) {
|
|
return {
|
|
args: rawArguments ?? {},
|
|
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey),
|
|
};
|
|
}
|
|
|
|
const { idempotency_key: bodyIdempotencyKey, ...args } = rawArguments;
|
|
|
|
return {
|
|
args,
|
|
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey ?? (typeof bodyIdempotencyKey === "string" ? bodyIdempotencyKey : null)),
|
|
};
|
|
}
|
|
|
|
function normalizeIdempotencyKey(value?: string | null): string | null {
|
|
const normalized = value?.trim();
|
|
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
if (normalized.length > 200) {
|
|
throw new ToolExecutionInputError("idempotency_key_invalid", "Idempotency key must be 200 characters or fewer.", 400);
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function hashToolRequest(name: string, args: unknown): string {
|
|
return createHash("sha256").update(stableStringify({ name, args })).digest("hex");
|
|
}
|
|
|
|
function stableStringify(value: unknown): string {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
}
|
|
|
|
if (isPlainRecord(value)) {
|
|
return `{${Object.keys(value)
|
|
.sort()
|
|
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
.join(",")}}`;
|
|
}
|
|
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function summarizeToolArguments(args: unknown): Record<string, unknown> {
|
|
if (!isPlainRecord(args)) {
|
|
return {};
|
|
}
|
|
|
|
return {
|
|
workspace_slug: args.workspace_slug,
|
|
project_id: args.project_id,
|
|
issue_id: args.issue_id,
|
|
state_id: args.state_id,
|
|
labels_count: Array.isArray(args.labels) ? args.labels.length : undefined,
|
|
has_structured_blocks: Array.isArray(args.structured_blocks),
|
|
};
|
|
}
|
|
|
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function buildAgentInstructions(session: AgentSessionRecord): Record<string, unknown> {
|
|
return {
|
|
agent: {
|
|
id: session.agent.id,
|
|
display_name: session.agent.displayName,
|
|
owner_user_id: session.agent.ownerUserId,
|
|
},
|
|
grants: session.grants.map((grant) => ({
|
|
workspace_slug: grant.workspaceSlug,
|
|
project_id: grant.projectId,
|
|
mode: grant.mode,
|
|
scopes: grant.scopes,
|
|
})),
|
|
rules: {
|
|
card_structure: [
|
|
"Keep the main issue description concise and conceptual.",
|
|
"Use structured text blocks for current architecture, planned architecture, and implementation notes.",
|
|
"Every structured text block must use its title field for the heading; do not duplicate markdown headings like ## Status inside the body.",
|
|
"Wrong structured text block: title omitted and body starts with ## Текущая архитектура. Correct: title is Текущая архитектура and body contains only the section content.",
|
|
"Use checker blocks with explicit titles for short verifiable phase items.",
|
|
"After implementation, add a factual implementation block with files touched and validation performed.",
|
|
],
|
|
labels: [
|
|
"Use tasker_ensure_labels before setting a label that is not already present in project context.",
|
|
"Use tasker_set_issue_labels with label ids returned by project context or tasker_ensure_labels.",
|
|
],
|
|
hard_limits: [
|
|
"Do not delete or archive issues.",
|
|
"Do not create projects, states, workspace invites, or workspace settings changes.",
|
|
"Only create labels through tasker_ensure_labels inside granted projects.",
|
|
"Only assign existing project members.",
|
|
"Only use projects and workspaces present in effective grants.",
|
|
],
|
|
reporting_mode: "If a grant has mode=reporting, keep issue status and comments up to date without pretending to enforce unmanaged local Codex execution.",
|
|
},
|
|
};
|
|
}
|