Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 114 additions & 26 deletions askable-gtm/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ interface InteractiveActor {
ownerKey: string;
reply(text: string): Promise<void>;
owns(watchOwner: string): boolean;
/**
* Transport-specific presentation for a machine-readable payload. Relay is a
* machine surface — `capabilities --json` is documented as a parseable
* response and an agent or catalog calls `JSON.parse` on it — so relay leaves
* it exactly as-is. Slack is a human surface, where an unfenced 4KB blob is
* an unreadable wall, so Slack fences it. Default: unchanged.
*/
presentJson?(json: string): string;
}

interface SlackDirectMessenger {
Expand Down Expand Up @@ -623,7 +631,15 @@ export async function handleSlackMessage(
{
ownerKey: slackOwnerKey(message.user),
owns: (owner) => owner === slackOwnerKey(message.user!),
reply: (text) => postReply(ctx, deps.slack ?? defaultSlack(), message, text),
// Answer a top-level mention in a thread beneath it. Several agents share
// one Slack identity, so a single `@Agent Relay` mention can draw a reply
// from each of them; threading keeps the channel readable and each answer
// attached to its question. This persona keeps no cross-turn Slack
// context, so moving the conversation unit to the thread costs nothing.
reply: (text) => postReply(ctx, deps.slack ?? defaultSlack(), message, text, {
startThread: true,
}),
presentJson: presentJsonForSlack,
},
stripLeadingMention(message.text).trim(),
gateway,
Expand All @@ -640,13 +656,8 @@ async function handleInteractiveCommand(
): Promise<void> {
const command = parseCommand(text);
if (command.kind === 'capabilities-json') {
await actor.reply(JSON.stringify({
type: 'askable.capabilities',
capability: ASKABLE_GTM_CAPABILITY,
runtimeDataAccess: gateway.status === 'configured'
? 'cloud-integration-action-gateway-configured-authorization-checked-per-request'
: 'blocked-missing-cloud-runtime-credentials',
}));
const json = renderCapabilitiesJson(gateway.status);
await actor.reply(actor.presentJson ? actor.presentJson(json) : json);
return;
}
if (command.kind === 'capabilities-human') {
Expand Down Expand Up @@ -861,7 +872,14 @@ export async function runWatchSweep(
return Boolean(id && !previous.has(id));
});
if (fresh.length > 0) {
await deliverToOwner(ctx, watch.owner, renderListenAnswer(watch.query, response, fresh, result.access));
// An interactive answer needs no header — the reader just typed the
// question. A watch delivery is unsolicited and may arrive hours later
// alongside other watches, so it must say which one fired.
await deliverToOwner(
ctx,
watch.owner,
`${renderWatchHeader(watch)}\n${renderListenAnswer(watch.query, response, fresh, result.access)}`,
);
}
await finalizeWatchRun(
store,
Expand Down Expand Up @@ -957,41 +975,65 @@ export function classifyListenCoverage(response: ListenResponse): ListenCoverage
};
}

/**
* Identify an unsolicited watch delivery. Compact on purpose: enough to tell
* which saved query fired and to `unwatch` it, without reintroducing the
* preamble that buries interactive answers.
*/
export function renderWatchHeader(watch: Pick<WatchDefinition, 'id' | 'query' | 'cadence'>): string {
return `New for \u201c${truncateEvidence(watch.query, 80)}\u201d · every ${watch.cadence} · ${watch.id}`;
}

export function renderListenAnswer(
query: string,
/** Retained for the signature; the answer no longer restates the question. */
_query: string,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
response: ListenResponse,
results: ListenResult[],
access?: ListenGatewayResult['access'],
): string {
const fetchedAt = response.meta?.fetched_at ?? 'not supplied';
const coverage = Object.entries(response.source_status ?? {})
.map(([source, status]) => `${source}:${status.status ?? 'unknown'} (${status.count ?? 0})`)
.join(', ') || 'not supplied';
const items = results.slice(0, 5).map((result, index) => {
const title = oneLine(result.title ?? result.body_text ?? 'Untitled public post');
const community = result.community?.name ? ` · ${result.community.name}` : '';
// Reddit rows carry a title; LinkedIn rows do not, so this falls back to
// the post body — which is a whole post. Cite an EXCERPT: the answer is a
// scannable index into the sources, and the link is right there for anyone
// who wants the rest.
const excerpt = truncateEvidence(
result.title ?? result.body_text ?? 'Untitled public post',
);
// Attribution: Reddit supplies a community, LinkedIn a public author.
const attribution = result.community?.name
? ` · ${result.community.name}`
: result.author?.handle
? ` · ${result.author.handle}`
: '';
const engagement = ` · score ${result.score_count ?? 0} · comments ${result.comment_count ?? 0}`;
const url = result.url ?? result.permalink ?? '';
return `${index + 1}. ${title}${community}${engagement}${url ? `\n${url}` : ''}`;
return `${index + 1}. ${excerpt}${attribution}${engagement}${url ? `\n${url}` : ''}`;
});
const report = classifyListenCoverage(response);
const failed = formatSourceList(report.failedSources);
// Lead with the answer. The question, the fetch timestamp, the endpoint and a
// standing caveat are all things the reader already knows or can see, and
// putting four lines of preamble above the results buries them.
//
// Two things survive the trim, because dropping them would make the answer
// dishonest rather than merely terse:
// - a missing source, stated compactly. Silently returning LinkedIn-only
// results while Reddit is down presents a partial view as a complete one.
// - the managed-access disclosure, which the capability manifest marks
// `disclosureRequired` because that path is metered and billable. A
// user's own connected credential needs no such notice.
return [
`Public-signal evidence for “${oneLine(query)}”`,
`Fetched: ${fetchedAt} · coverage: ${coverage}`,
...(access ? [renderAccessDisclosure(access)] : []),
...(report.coverage === 'degraded'
? [`Partial coverage: ${failed} failed this request, so anything posted there is missing below.`]
: []),
items.length
? items.join('\n')
: report.coverage === 'failed'
? `No evidence could be gathered: every queried source failed (${failed}). `
+ 'This is a source outage, not a finding that nothing was posted. Retry later.'
: 'No results were returned.',
...(report.coverage === 'failed'
? []
: ['Source facts are shown above; themes or intent require separate inference.']),
...(report.coverage === 'degraded' ? [`(${failed} unavailable this request)`] : []),
// `unknown` discloses too: the gateway did not say whether this was the
// user's credential or the metered managed one, and staying quiet about a
// charge that might be billable is the wrong way to be wrong.
...(access && access.credentialSource !== 'user' ? [renderAccessDisclosure(access)] : []),
].join('\n');
}

Expand All @@ -1001,6 +1043,34 @@ function formatSourceList(sources: readonly string[]): string {
return `${sources.slice(0, -1).join(', ')} and ${sources[sources.length - 1]}`;
}

/**
* The machine-readable manifest, as standalone parseable JSON. Presentation is
* the transport's business: `ASKABLE_AGENTS.md` documents this command as a
* machine-readable relay response, so wrapping it here would break
* `JSON.parse` for every agent and catalog that calls it.
*/
export function renderCapabilitiesJson(gatewayStatus: ListenGateway['status']): string {
return JSON.stringify({
type: 'askable.capabilities',
capability: ASKABLE_GTM_CAPABILITY,
runtimeDataAccess: gatewayStatus === 'configured'
? 'cloud-integration-action-gateway-configured-authorization-checked-per-request'
: 'blocked-missing-cloud-runtime-credentials',
}, null, 2);
}

/** Slack presentation: fence the payload so Slack renders it as code. */
export function presentJsonForSlack(json: string): string {
return [
'GTM Signal Scout — machine-readable capability manifest.',
'You can also just ask a GTM question in plain language, or send'
+ ' \u201cwhat can you tell me?\u201d for the short version.',
'```json',
Comment on lines +1065 to +1068

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep relay capability responses valid JSON

When an agent or catalog invokes capabilities --json over Relay, handleRelayMessage now returns these prose and fence lines around the payload, so parsing the response directly with JSON.parse fails. This affects Relay as well as Slack because both transports share handleInteractiveCommand, despite the documented contract in ASKABLE_AGENTS.md describing this command as a machine-readable relay response; apply Slack-only presentation formatting or leave the relay response as standalone JSON.

Useful? React with 👍 / 👎.

json,
'```',
].join('\n');
}

export function renderCapabilities(gatewayStatus: ListenGateway['status']): string {
const watchOperation = ASKABLE_GTM_CAPABILITY.operations.find(
(operation) => operation.id === 'manage-watch-definitions',
Expand Down Expand Up @@ -1449,6 +1519,24 @@ function oneLine(value: string): string {
return value.replace(/\s+/gu, ' ').trim();
}

/** Longest cited excerpt. Enough to judge relevance, short enough to scan. */
export const EVIDENCE_EXCERPT_MAX = 180;

/**
* One-line excerpt of a post, cut on a word boundary. A citation is a pointer
* to a source, not a copy of it: pasting five full LinkedIn posts into a Slack
* thread buries the very signal the answer exists to surface.
*/
export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
const single = oneLine(value);
if (single.length <= max) return single;
const clipped = single.slice(0, max);
const lastSpace = clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
const body = lastSpace > max * 0.6 ? clipped.slice(0, lastSpace) : clipped;
return `${body.replace(/[\s.,;:!?—-]+$/u, '')}…`;
Comment on lines +1530 to +1537

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a post has a non-BMP character at the excerpt boundary, truncateEvidence splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At askable-gtm/agent.ts, line 1510:

<comment>When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</comment>

<file context>
@@ -1488,6 +1499,24 @@ function oneLine(value: string): string {
+ * to a source, not a copy of it: pasting five full LinkedIn posts into a Slack
+ * thread buries the very signal the answer exists to surface.
+ */
+export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
+  const single = oneLine(value);
+  if (single.length <= max) return single;
</file context>
Suggested change
export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
const single = oneLine(value);
if (single.length <= max) return single;
const clipped = single.slice(0, max);
const lastSpace = clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
const body = lastSpace > max * 0.6 ? clipped.slice(0, lastSpace) : clipped;
return `${body.replace(/[\s.,;:!?—-]+$/u, '')}…`;
export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
const characters = [...oneLine(value)];
if (characters.length <= max) return characters.join('');
const clipped = characters.slice(0, max);
const lastSpace = clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
const body = lastSpace > max * 0.6
? clipped.slice(0, lastSpace).join('')
: clipped.join('');
return `${body.replace(/[\s.,;:!?—-]+$/u, '')}…`;
}

}

function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
Expand Down
41 changes: 35 additions & 6 deletions shared/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,20 @@ export function bareChannelId(channel: string): string {
* Conversation key for continuity. A threaded message keys on its thread; a
* top-level message keys on the CHANNEL itself, so consecutive top-level
* messages in a dedicated chat channel form one continuous conversation.
*
* `startThread` must match what was passed to {@link postReply}. An agent that
* answers a top-level mention in a new thread has made that thread the
* conversation, so the opening turn keys on its own ts — otherwise the reply
* lands under `channel:ts` while the opening turn sits under `channel`, and the
* follow-up prompt loses the question it is answering.
*/
export function conversationKeyForSlack(msg: SlackMessage): string {
export function conversationKeyForSlack(
msg: SlackMessage,
options: SlackReplyOptions = {},
): string {
const chanId = bareChannelId(msg.channel);
return msg.threadTs ? `${chanId}:${msg.threadTs}` : chanId;
const threadTs = msg.threadTs ?? (options.startThread ? msg.ts : undefined);
return threadTs ? `${chanId}:${threadTs}` : chanId;
}

/**
Expand All @@ -123,17 +133,36 @@ export function skipReason(msg: SlackMessage, boardChannel: string | undefined):

/** Post the reply (threaded if the incoming message was in a thread). Loud-ish:
* a missing receipt is logged (cloud writeback often outruns the wait). */
export type SlackReplyOptions = {
/**
* Answer a top-level mention in a NEW thread beneath it rather than in the
* channel. Off by default; see the note in the body before enabling.
*/
startThread?: boolean;
};

export async function postReply(
ctx: WorkforceCtx,
slack: SlackPoster,
msg: SlackMessage,
text: string
text: string,
options: SlackReplyOptions = {}
): Promise<void> {
const chanId = bareChannelId(msg.channel);
const result = msg.threadTs
? await slack.reply(chanId, msg.threadTs, text)
// `startThread` opts an agent into answering a TOP-LEVEL mention inside a new
// thread under it. It is opt-in, not the default, because it changes the
// conversation unit: `conversationKeyForSlack` keys a top-level message on
// the channel so consecutive top-level messages form one continuous
// conversation, and silently threading them would strand that history under
// a different key (see inbox-buddy's multi-turn context).
//
// An agent that opts in must also key continuity on the thread — use
// `conversationKeyForSlack(msg, { startThread: true })`.
const threadTs = msg.threadTs ?? (options.startThread ? msg.ts : undefined);
const result = threadTs
? await slack.reply(chanId, threadTs, text)
: await slack.post(chanId, text);
if (!result?.ts) {
ctx.log?.('warn', 'slack.reply.no-receipt', { channel: chanId, threaded: Boolean(msg.threadTs) });
ctx.log?.('warn', 'slack.reply.no-receipt', { channel: chanId, threaded: Boolean(threadTs) });
}
}
Loading