FEAT - GATEWAY: support Tasker labels and agent metadata
This commit is contained in:
parent
19d5f18bf5
commit
f52a8345a1
|
|
@ -23,6 +23,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||
internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN,
|
||||
});
|
||||
const app = Fastify({
|
||||
bodyLimit: 10 * 1024 * 1024,
|
||||
logger: {
|
||||
level: config.LOG_LEVEL,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -73,10 +73,16 @@ const structuredBlocksJsonSchema = {
|
|||
properties: {
|
||||
id: { type: "string" },
|
||||
type: { const: "text" },
|
||||
title: { type: "string" },
|
||||
body: { type: "string" },
|
||||
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"],
|
||||
required: ["id", "type", "title", "body"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -84,7 +90,10 @@ const structuredBlocksJsonSchema = {
|
|||
properties: {
|
||||
id: { type: "string" },
|
||||
type: { const: "checker" },
|
||||
title: { type: "string" },
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Visible checklist title. Put headings here, not inside item text.",
|
||||
},
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
|
|
@ -99,7 +108,7 @@ const structuredBlocksJsonSchema = {
|
|||
},
|
||||
},
|
||||
},
|
||||
required: ["id", "type"],
|
||||
required: ["id", "type", "title", "items"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
|
|
@ -236,10 +245,42 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
|||
},
|
||||
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 labels from the granted project.",
|
||||
description: "Replace issue labels with existing or ensured labels from the granted project.",
|
||||
requiredScopes: ["issue:label"],
|
||||
inputSchema: {
|
||||
...projectAndIssueInputSchema,
|
||||
|
|
@ -308,6 +349,20 @@ const moveIssueArgsSchema = issueArgsSchema.extend({
|
|||
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([]),
|
||||
});
|
||||
|
|
@ -454,6 +509,11 @@ async function executeMcpToolOnce(
|
|||
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);
|
||||
|
|
@ -580,6 +640,7 @@ function summarizeToolArguments(args: unknown): Record<string, unknown> {
|
|||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -605,12 +666,19 @@ function buildAgentInstructions(session: AgentSessionRecord): Record<string, unk
|
|||
card_structure: [
|
||||
"Keep the main issue description concise and conceptual.",
|
||||
"Use structured text blocks for current architecture, planned architecture, and implementation notes.",
|
||||
"Use checker blocks for short verifiable phase items.",
|
||||
"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, labels, states, workspace invites, or workspace settings changes.",
|
||||
"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.",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ type AgentRouteDeps = {
|
|||
internalAccessToken?: string;
|
||||
};
|
||||
|
||||
const MAX_AGENT_AVATAR_URL_LENGTH = 400_000;
|
||||
const MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000;
|
||||
|
||||
function isAllowedAvatarUrl(value: string): boolean {
|
||||
if (value.startsWith("data:image/")) {
|
||||
|
|
@ -678,14 +678,23 @@ function buildAgentsMd(session: AgentSessionRecord, endpoint: string): string {
|
|||
"- Never delete or archive Tasker cards, comments, labels, projects, states, members, or workspaces.",
|
||||
"- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.",
|
||||
"- Only assign existing project members returned by project context.",
|
||||
"- If a needed label is missing, call `tasker_ensure_labels` first and then use returned ids with `tasker_set_issue_labels`.",
|
||||
"",
|
||||
"## Card Writing",
|
||||
"",
|
||||
"- Keep card titles concise and operational.",
|
||||
"- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.",
|
||||
"- Put short verifiable work items into checker blocks.",
|
||||
"- Put each structured block heading into the block `title` field. Do not put markdown headings like `## Status` inside block body.",
|
||||
"- Do not put headings inside block body. Wrong: body starts with `## Текущая архитектура`. Correct: `title: \"Текущая архитектура\"`, body contains only content.",
|
||||
"- Put short verifiable work items into checker blocks with explicit titles.",
|
||||
"- After code work, update the related card with factual files touched and validation performed.",
|
||||
"",
|
||||
"## Labels",
|
||||
"",
|
||||
"- Before assigning a new marker/label, check project context labels.",
|
||||
"- If the label does not exist, call `tasker_ensure_labels` with the granted `project_id`.",
|
||||
"- Use only label ids returned by project context or `tasker_ensure_labels` when calling `tasker_set_issue_labels`.",
|
||||
"",
|
||||
"## Effective Grants",
|
||||
"",
|
||||
grants || "- No grants available.",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,22 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
|
|||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/projects/:projectId/labels/ensure", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { projectId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_ensure_labels",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
project_id: params.projectId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.get("/api/v1/tools/issues", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };
|
||||
|
|
|
|||
|
|
@ -83,6 +83,15 @@ export type SetLabelsInput = {
|
|||
label_ids: string[];
|
||||
};
|
||||
|
||||
export type EnsureLabelsInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
labels: Array<{
|
||||
name: string;
|
||||
color?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AssignIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
|
|
@ -177,6 +186,14 @@ export class TaskerClient {
|
|||
});
|
||||
}
|
||||
|
||||
async ensureLabels(session: AgentSessionRecord, input: EnsureLabelsInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(input.project_id)}/labels/ensure`, {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async assignIssue(session: AgentSessionRecord, issueId: string, input: AssignIssueInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, {
|
||||
method: "PUT",
|
||||
|
|
@ -217,6 +234,7 @@ export class TaskerClient {
|
|||
}
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const requestBody = attachAgentMetadata(input.session, input.body);
|
||||
return await fetch(new URL(path, this.config.baseUrl), {
|
||||
method: input.method,
|
||||
headers: {
|
||||
|
|
@ -226,7 +244,7 @@ export class TaskerClient {
|
|||
"X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId,
|
||||
"X-NODEDC-Agent-Token-Id": input.session.token.id,
|
||||
},
|
||||
body: input.body === undefined ? undefined : JSON.stringify(input.body),
|
||||
body: requestBody === undefined ? undefined : JSON.stringify(requestBody),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TaskerAdapterUnavailableError(error);
|
||||
|
|
@ -234,6 +252,24 @@ export class TaskerClient {
|
|||
}
|
||||
}
|
||||
|
||||
function attachAgentMetadata(session: AgentSessionRecord, body: unknown): unknown {
|
||||
if (body === undefined || !isPlainRecord(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return {
|
||||
...body,
|
||||
_agent: {
|
||||
display_name: session.agent.displayName,
|
||||
avatar_url: session.agent.avatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function readResponsePayload(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue