Skip to content

Commit 200b527

Browse files
committed
feat(linear): teach agent to fetch attachments + docs via Linear MCP
Linear-channel tasks now know how (and when) to discover paperclip attachments, project documents, and post-task-start comments via the already-loaded `mcp__linear-server__*` tools. The webhook processor issues a single GraphQL probe per triggered issue to flag presence of attachments / project docs and prepends a one-line hint to the task description so the agent has a reason to invoke the MCP. No new IAM, tables, env vars, or CDK constructs — purely prompt + GraphQL. The screened description-image pipeline (`extractImageUrlAttachments`) is unchanged; embedded `![alt](https://…)` images still go through Bedrock Guardrails at task-creation time. The new MCP path is additive coverage for paperclips, project wikis, and live comments. (cherry picked from commit 8a75afc)
1 parent f241a71 commit 200b527

7 files changed

Lines changed: 531 additions & 4 deletions

File tree

agent/src/prompt_builder.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ def _channel_prompt_addendum(config: TaskConfig) -> str:
155155
return ""
156156
issue_identifier = config.channel_metadata.get("linear_issue_identifier") or ""
157157
issue_ref = f" (`{issue_identifier}`)" if issue_identifier else ""
158+
issue_id = config.channel_metadata.get("linear_issue_id") or ""
159+
project_id = config.channel_metadata.get("linear_project_id") or ""
158160
return (
159161
"\n\n## Linear issue progress updates (REQUIRED)\n\n"
160162
f"This task was submitted from Linear issue{issue_ref}. The Linear MCP "
@@ -182,7 +184,47 @@ def _channel_prompt_addendum(config: TaskConfig) -> str:
182184
"agent-side completion comment would just stack two near-identical "
183185
"comments on the issue.\n\n"
184186
"Keep the start + PR-opened comments concise. Do not mirror the full "
185-
"agent transcript back to Linear."
187+
"agent transcript back to Linear.\n\n"
188+
"## Linear context discovery (on demand)\n\n"
189+
"The same Linear MCP exposes tools for fetching extra context on the "
190+
"issue when you need it. Use them sparingly — only when the task "
191+
"description references material you don't have, when the description "
192+
"is ambiguous and project-level context would clarify, or when a "
193+
"decision point benefits from a fresh look at the issue thread. Do "
194+
"NOT call these on every task; the issue title + description are "
195+
"usually sufficient.\n\n"
196+
f"- **Issue + paperclip attachments.** Call `mcp__linear-server__get_issue` "
197+
f'with `id: "{issue_id}"` to fetch the full issue, including its '
198+
"`attachments` connection (paperclip-icon files like PDFs, logs, "
199+
"spec docs that aren't embedded as markdown images). Read the "
200+
"attachment titles first; for each one that looks relevant, call "
201+
"`mcp__linear-server__get_attachment` with that attachment id. Skip "
202+
"ones that look unrelated (e.g. screenshots from prior debugging "
203+
"sessions).\n"
204+
"- **Embedded images.** Description and comment images that look "
205+
"like `![alt](https://uploads.linear.app/…)` may have stale signed "
206+
"URLs by the time you run. If you need to actually look at one, call "
207+
"`mcp__linear-server__extract_images` to get fresh signed URLs, then "
208+
"use the built-in `WebFetch` tool to download. (The screened "
209+
"description-image path runs at task-creation time and is separate "
210+
"from this — you don't need to re-screen.)\n"
211+
"- **Project documents.** When the issue belongs to a project and "
212+
"the task is ambiguous enough that project-level context (specs, "
213+
"design docs, RFCs) would help, call "
214+
f"`mcp__linear-server__list_documents` filtered to "
215+
f'`projectId: "{project_id}"` (skip if the issue has no project). '
216+
"Read the titles. For documents that clearly relate to your task, "
217+
"call `mcp__linear-server__get_document` to read the body. Don't "
218+
"fetch every document.\n"
219+
"- **Comments posted after task start.** Comments left while you're "
220+
"running (e.g. clarifications, approve/deny signals from the "
221+
"requester) are not in your task description. Before opening the PR, "
222+
f"and again before merging if asked, call `mcp__linear-server__list_comments` "
223+
f'with `issueId: "{issue_id}"` and look for new comments since '
224+
"task start. Respect any clear approve / deny / block / hold signals "
225+
"from the original requester (the issue creator or the person who "
226+
"applied the trigger label) — if they say stop, stop and post a "
227+
"comment explaining why."
186228
)
187229

188230

agent/tests/test_entrypoint.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,3 +500,106 @@ def test_selects_pr_review_prompt(self):
500500
assert "READ-ONLY" in prompt
501501
assert "must NOT modify" in prompt
502502
assert "55" in prompt
503+
504+
505+
# ---------------------------------------------------------------------------
506+
# _build_system_prompt — Linear channel addendum
507+
# ---------------------------------------------------------------------------
508+
509+
510+
class TestBuildSystemPromptLinearChannel:
511+
"""The Linear-channel addendum is appended only for channel_source=='linear'."""
512+
513+
def _setup(self) -> RepoSetup:
514+
return RepoSetup(
515+
repo_dir="/workspace/t1",
516+
branch="b",
517+
default_branch="main",
518+
notes=[],
519+
)
520+
521+
def test_no_addendum_when_channel_is_blank(self):
522+
config = TaskConfig(
523+
repo_url="o/r",
524+
task_id="t1",
525+
max_turns=10,
526+
github_token="ghp_test",
527+
aws_region="us-east-1",
528+
)
529+
prompt = _build_system_prompt(config, self._setup(), None, "")
530+
assert "Linear issue progress updates" not in prompt
531+
assert "Linear context discovery" not in prompt
532+
533+
def test_no_addendum_for_slack_channel(self):
534+
config = TaskConfig(
535+
repo_url="o/r",
536+
task_id="t1",
537+
max_turns=10,
538+
github_token="ghp_test",
539+
aws_region="us-east-1",
540+
channel_source="slack",
541+
)
542+
prompt = _build_system_prompt(config, self._setup(), None, "")
543+
assert "Linear issue progress updates" not in prompt
544+
assert "Linear context discovery" not in prompt
545+
546+
def test_addendum_present_for_linear_channel(self):
547+
config = TaskConfig(
548+
repo_url="o/r",
549+
task_id="t1",
550+
max_turns=10,
551+
github_token="ghp_test",
552+
aws_region="us-east-1",
553+
channel_source="linear",
554+
channel_metadata={
555+
"linear_issue_id": "issue-uuid-1",
556+
"linear_issue_identifier": "ABC-42",
557+
"linear_project_id": "project-uuid-1",
558+
},
559+
)
560+
prompt = _build_system_prompt(config, self._setup(), None, "")
561+
assert "Linear issue progress updates" in prompt
562+
assert "Linear context discovery" in prompt
563+
assert "ABC-42" in prompt
564+
565+
def test_linear_addendum_names_attachment_tools(self):
566+
# The agent must know the exact MCP tool names — vague references
567+
# would cause it to grope. Lock these in so a rename triggers the test.
568+
config = TaskConfig(
569+
repo_url="o/r",
570+
task_id="t1",
571+
max_turns=10,
572+
github_token="ghp_test",
573+
aws_region="us-east-1",
574+
channel_source="linear",
575+
channel_metadata={"linear_issue_id": "issue-uuid-1"},
576+
)
577+
prompt = _build_system_prompt(config, self._setup(), None, "")
578+
for tool in (
579+
"mcp__linear-server__get_issue",
580+
"mcp__linear-server__get_attachment",
581+
"mcp__linear-server__extract_images",
582+
"mcp__linear-server__list_documents",
583+
"mcp__linear-server__get_document",
584+
"mcp__linear-server__list_comments",
585+
):
586+
assert tool in prompt, f"expected {tool} to be named in the Linear addendum"
587+
588+
def test_linear_addendum_inlines_issue_id_and_project_id(self):
589+
config = TaskConfig(
590+
repo_url="o/r",
591+
task_id="t1",
592+
max_turns=10,
593+
github_token="ghp_test",
594+
aws_region="us-east-1",
595+
channel_source="linear",
596+
channel_metadata={
597+
"linear_issue_id": "issue-uuid-deadbeef",
598+
"linear_project_id": "project-uuid-cafebabe",
599+
},
600+
)
601+
prompt = _build_system_prompt(config, self._setup(), None, "")
602+
# The agent shouldn't have to guess the ids — they're in the metadata,
603+
# so we surface them directly in the prompt.
604+
assert "issue-uuid-deadbeef" in prompt
605+
assert "project-uuid-cafebabe" in prompt

cdk/src/handlers/linear-webhook-processor.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2222
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
2323
import { createTaskCore } from './shared/create-task-core';
2424
import { reactToComment, replyToComment, reportIssueFailure, EMOJI_STARTED } from './shared/linear-feedback';
25+
import {
26+
probeLinearIssueContext,
27+
renderIssueContextHint,
28+
} from './shared/linear-issue-context-probe';
2529
import { resolveLinearOauthToken } from './shared/linear-oauth-resolver';
2630
import { fetchIssueParentId } from './shared/linear-subissue-fetch';
2731
import { resolveTaskByLinearIssue, prNumberFromTask } from './shared/linear-task-by-issue';
@@ -311,8 +315,6 @@ export async function handler(event: ProcessorEvent): Promise<void> {
311315
return;
312316
}
313317

314-
const taskDescription = buildTaskDescription(issue);
315-
316318
const channelMetadata: Record<string, string> = {
317319
linear_issue_id: issue.id,
318320
linear_workspace_id: workspaceId,
@@ -343,6 +345,7 @@ export async function handler(event: ProcessorEvent): Promise<void> {
343345
// is guaranteed present (we return otherwise), so the token is set
344346
// whenever the registry table is configured.
345347
let resolvedAccessToken: string | undefined;
348+
let contextHint = '';
346349
if (WORKSPACE_REGISTRY_TABLE) {
347350
const resolved = await resolveLinearOauthToken(workspaceId, WORKSPACE_REGISTRY_TABLE);
348351
if (!resolved) {
@@ -355,6 +358,12 @@ export async function handler(event: ProcessorEvent): Promise<void> {
355358
channelMetadata.linear_oauth_secret_arn = resolved.oauthSecretArn;
356359
channelMetadata.linear_workspace_slug = resolved.workspaceSlug;
357360
resolvedAccessToken = resolved.accessToken;
361+
// Best-effort presence probe: ask Linear once whether the issue has
362+
// paperclip attachments or sits in a project with documents. The agent
363+
// will fetch the actual content via the Linear MCP at runtime — this
364+
// step only flags that there's something worth fetching.
365+
const probe = await probeLinearIssueContext(resolved.accessToken, issue.id);
366+
contextHint = renderIssueContextHint(probe);
358367
}
359368

360369
// #247 Mode A — parent/sub-issue orchestration. Env-var gated: until
@@ -577,6 +586,8 @@ export async function handler(event: ProcessorEvent): Promise<void> {
577586
// path below (issue had no sub-issues).
578587
}
579588

589+
const taskDescription = buildTaskDescription(issue, contextHint);
590+
580591
// Extract embedded image URLs from the issue description markdown.
581592
// These become URL attachments that are fetched and screened during context hydration.
582593
const attachments = extractImageUrlAttachments(issue.description);
@@ -1098,13 +1109,17 @@ function buildCreateTaskFailureMessage(statusCode: number, rawBody: string): str
10981109
return `❌ ABCA couldn't create this task (status ${statusCode}). Check the ABCA admin logs for details.`;
10991110
}
11001111

1101-
function buildTaskDescription(issue: LinearIssueEvent['data']): string {
1112+
function buildTaskDescription(issue: LinearIssueEvent['data'], contextHint: string = ''): string {
11021113
const parts: string[] = [];
11031114
if (issue.identifier && issue.title) {
11041115
parts.push(`${issue.identifier}: ${issue.title}`);
11051116
} else if (issue.title) {
11061117
parts.push(issue.title);
11071118
}
1119+
if (contextHint) {
1120+
parts.push('');
1121+
parts.push(contextHint);
1122+
}
11081123
if (issue.description && issue.description.trim()) {
11091124
parts.push('');
11101125
parts.push(issue.description.trim());
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { logger } from './logger';
21+
22+
/**
23+
* Best-effort probe for additional Linear context attached to an issue —
24+
* paperclip attachments and project documents — that the agent should
25+
* fetch on demand via the Linear MCP at runtime.
26+
*
27+
* The webhook payload itself does NOT carry attachments or
28+
* project.documents, so we ask Linear's GraphQL API once at task-creation
29+
* time. The result is a tiny presence signal (titles + counts) that lets
30+
* the webhook processor prepend a hint to the task description; it does
31+
* NOT pre-fetch bodies, screen content, or upload to S3 — that path is
32+
* still owned by `extractImageUrlAttachments` for description-embedded
33+
* markdown images.
34+
*/
35+
36+
const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql';
37+
const REQUEST_TIMEOUT_MS = 5000;
38+
39+
const ISSUE_CONTEXT_QUERY = `
40+
query IssueContext($id: String!) {
41+
issue(id: $id) {
42+
id
43+
attachments(first: 25) {
44+
nodes {
45+
id
46+
title
47+
}
48+
}
49+
project {
50+
id
51+
name
52+
documents(first: 1) {
53+
nodes { id }
54+
}
55+
}
56+
}
57+
}
58+
`.trim();
59+
60+
export interface LinearIssueContextProbe {
61+
/** Paperclip attachment titles surfaced on the issue, if any. */
62+
readonly attachmentTitles: readonly string[];
63+
/** Project name (only present when the issue belongs to a project). */
64+
readonly projectName: string | null;
65+
/** True when the issue's project has at least one document attached. */
66+
readonly projectHasDocuments: boolean;
67+
}
68+
69+
const EMPTY: LinearIssueContextProbe = {
70+
attachmentTitles: [],
71+
projectName: null,
72+
projectHasDocuments: false,
73+
};
74+
75+
/**
76+
* Issue the GraphQL query. Returns an empty probe on any failure
77+
* (network, auth, GraphQL errors). Never throws — the caller treats
78+
* absence of context the same as no extra context being available.
79+
*/
80+
export async function probeLinearIssueContext(
81+
accessToken: string,
82+
issueId: string,
83+
): Promise<LinearIssueContextProbe> {
84+
const controller = new AbortController();
85+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
86+
try {
87+
const resp = await fetch(LINEAR_GRAPHQL_URL, {
88+
method: 'POST',
89+
headers: {
90+
'Authorization': `Bearer ${accessToken}`,
91+
'Content-Type': 'application/json',
92+
},
93+
body: JSON.stringify({
94+
query: ISSUE_CONTEXT_QUERY,
95+
variables: { id: issueId },
96+
}),
97+
signal: controller.signal,
98+
});
99+
if (!resp.ok) {
100+
logger.warn('Linear issue context probe non-2xx', { status: resp.status, issue_id: issueId });
101+
return EMPTY;
102+
}
103+
const body = (await resp.json()) as {
104+
data?: {
105+
issue?: {
106+
attachments?: { nodes?: Array<{ id?: string; title?: string }> };
107+
project?: {
108+
id?: string;
109+
name?: string;
110+
documents?: { nodes?: Array<{ id?: string }> };
111+
} | null;
112+
};
113+
};
114+
errors?: unknown;
115+
};
116+
if (body.errors) {
117+
logger.warn('Linear issue context probe graphql errors', { issue_id: issueId, errors: body.errors });
118+
return EMPTY;
119+
}
120+
const issue = body.data?.issue;
121+
if (!issue) return EMPTY;
122+
const attachmentTitles = (issue.attachments?.nodes ?? [])
123+
.map((a) => (typeof a?.title === 'string' ? a.title.trim() : ''))
124+
.filter((t): t is string => t.length > 0);
125+
const project = issue.project ?? null;
126+
const projectName = typeof project?.name === 'string' && project.name.trim() ? project.name.trim() : null;
127+
const projectHasDocuments = (project?.documents?.nodes ?? []).length > 0;
128+
return { attachmentTitles, projectName, projectHasDocuments };
129+
} catch (err) {
130+
logger.warn('Linear issue context probe request failed', {
131+
issue_id: issueId,
132+
error: err instanceof Error ? err.message : String(err),
133+
});
134+
return EMPTY;
135+
} finally {
136+
clearTimeout(timer);
137+
}
138+
}
139+
140+
/**
141+
* Render a one-paragraph hint the webhook processor prepends to the task
142+
* description when the probe surfaced anything worth flagging. Returns
143+
* an empty string when there's nothing to hint about — the processor
144+
* skips the prepend in that case.
145+
*
146+
* The wording deliberately points at MCP tool names so the agent's
147+
* channel-prompt addendum reinforces (and is reinforced by) the same
148+
* vocabulary.
149+
*/
150+
export function renderIssueContextHint(probe: LinearIssueContextProbe): string {
151+
const bits: string[] = [];
152+
if (probe.attachmentTitles.length > 0) {
153+
const titles = probe.attachmentTitles.slice(0, 5).map((t) => `"${t}"`).join(', ');
154+
const more = probe.attachmentTitles.length > 5 ? ` (+${probe.attachmentTitles.length - 5} more)` : '';
155+
bits.push(`paperclip attachments — ${titles}${more} (fetch via \`mcp__linear-server__get_issue\` then \`mcp__linear-server__get_attachment\`)`);
156+
}
157+
if (probe.projectHasDocuments && probe.projectName) {
158+
bits.push(`project "${probe.projectName}" has wiki documents (browse with \`mcp__linear-server__list_documents\` if the task is ambiguous)`);
159+
} else if (probe.projectHasDocuments) {
160+
bits.push('the project has wiki documents (browse with `mcp__linear-server__list_documents` if the task is ambiguous)');
161+
}
162+
if (bits.length === 0) return '';
163+
return `Linear may have additional context for this issue: ${bits.join('; ')}.`;
164+
}

0 commit comments

Comments
 (0)