release: v2.0.5 - Telegram Business Reporting & Core Resilience
This commit is contained in:
@@ -50,7 +50,8 @@ import {
|
||||
writeReport,
|
||||
writeSessionJson,
|
||||
} from './sessionStore';
|
||||
import { AgentTurnOutput, CompanyTaskPlan, SessionResult } from './types';
|
||||
import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport';
|
||||
import { AgentTurnOutput, CompanyState, CompanyTaskPlan, SessionResult } from './types';
|
||||
|
||||
/** Trim length applied when an agent's output is fed into the next agent. */
|
||||
const PEER_OUTPUT_BUDGET = 1500;
|
||||
@@ -68,6 +69,13 @@ export type CompanyTurnEvent =
|
||||
| { phase: 'agent-done'; agentId: string; output: AgentTurnOutput; index: number; total: number }
|
||||
| { phase: 'report-start' }
|
||||
| { phase: 'report-done'; report: string; ok: boolean }
|
||||
/**
|
||||
* Emitted after the secretary attempts to mirror the CEO report to
|
||||
* Telegram. `ok=true` ⇒ delivered. `ok=false` ⇒ delivery failed (network
|
||||
* / API error). `ok=null` ⇒ no mirror was attempted because the user
|
||||
* hasn't opted in (no token, no chat id, or `telegram.enabled=false`).
|
||||
*/
|
||||
| { phase: 'telegram-mirror'; ok: boolean | null; reason?: string }
|
||||
| { phase: 'session-saved'; sessionDir: string }
|
||||
| { phase: 'aborted'; reason: string };
|
||||
|
||||
@@ -78,6 +86,15 @@ export interface DispatcherDeps {
|
||||
ai: IAIService;
|
||||
/** Default model to fall back to when an agent has no override. */
|
||||
defaultModel: string;
|
||||
/**
|
||||
* Apply ConnectAI's action-tag executor to the specialist's raw response.
|
||||
* Without this hook, agent outputs containing `<create_file>` etc. would
|
||||
* be shown to the user as a *claim* of file creation but nothing would
|
||||
* actually land on disk. We thread it through deps (rather than
|
||||
* importing AgentExecutor directly) so the dispatcher stays free of
|
||||
* sidebar / agent.ts dependencies for unit testability.
|
||||
*/
|
||||
executeActionTags?: (text: string) => Promise<string[]>;
|
||||
/** Per-call cancellation. The sidebar's Stop button flips this. */
|
||||
signal?: AbortSignal;
|
||||
/** Optional event sink for the webview. Receives events synchronously. */
|
||||
@@ -160,6 +177,35 @@ export async function runCompanyTurn(
|
||||
writeReport(sessionDir, reportResult.report);
|
||||
emit({ phase: 'report-done', report: reportResult.report, ok: reportResult.ok });
|
||||
|
||||
// ── Phase 3.5: Secretary mirror to Telegram ──
|
||||
// Origin parity (Connect_origin extension.ts ~line 20620): after the CEO
|
||||
// synthesizes the round, the Secretary (영숙) ships a digest to the user's
|
||||
// Telegram chat. Strictly best-effort — failures never break the turn.
|
||||
let telegramOk: boolean | null = null;
|
||||
let telegramReason: string | undefined;
|
||||
try {
|
||||
const reporter = await buildTelegramReporter(deps.context);
|
||||
if (!reporter) {
|
||||
telegramReason = 'no-config'; // opt-in off, no token, or no chat id
|
||||
} else {
|
||||
const tgText = formatCompanyTelegramReport({
|
||||
state,
|
||||
userPrompt,
|
||||
plan: plannerResult.plan,
|
||||
outputs,
|
||||
report: reportResult.report,
|
||||
sessionTimestamp: timestamp,
|
||||
});
|
||||
telegramOk = await reporter(tgText);
|
||||
if (!telegramOk) telegramReason = 'delivery-failed';
|
||||
}
|
||||
} catch (e: any) {
|
||||
telegramOk = false;
|
||||
telegramReason = e?.message ?? String(e);
|
||||
logError('company.dispatcher: telegram mirror threw.', { error: telegramReason });
|
||||
}
|
||||
emit({ phase: 'telegram-mirror', ok: telegramOk, reason: telegramReason });
|
||||
|
||||
// ── Phase 4: persist + side effects ──
|
||||
const result: SessionResult = {
|
||||
timestamp, sessionDir,
|
||||
@@ -232,12 +278,31 @@ async function _dispatchOne(
|
||||
user: task,
|
||||
model,
|
||||
});
|
||||
const response = (result.content || '').trim();
|
||||
const rawResponse = (result.content || '').trim();
|
||||
// Apply ConnectAI's action-tag executor so `<create_file>`,
|
||||
// `<run_command>`, `<edit_file>`, etc. emitted by the agent actually
|
||||
// hit disk / shell. The report (e.g. "✅ Created: foo.py") is
|
||||
// appended to the response so the user sees what really happened.
|
||||
let finalResponse = rawResponse || '_(empty response)_';
|
||||
if (rawResponse && deps.executeActionTags && _hasActionTag(rawResponse)) {
|
||||
try {
|
||||
const report = await deps.executeActionTags(rawResponse);
|
||||
if (report.length > 0) {
|
||||
finalResponse = `${rawResponse}\n\n---\n**Action 실행 결과:**\n${report.map((r) => `- ${r}`).join('\n')}`;
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Surface the failure but keep the agent's text — partial
|
||||
// success is more useful than dropping the whole response.
|
||||
const err = e?.message ?? String(e);
|
||||
logError('company.dispatcher: action-tag execution failed.', { agentId, err });
|
||||
finalResponse = `${rawResponse}\n\n---\n⚠️ Action 실행 실패: ${err}`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
agentId, task,
|
||||
response: response || '_(empty response)_',
|
||||
response: finalResponse,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: response ? undefined : 'empty-response',
|
||||
error: rawResponse ? undefined : 'empty-response',
|
||||
};
|
||||
} catch (e: any) {
|
||||
const err = e?.message ?? String(e);
|
||||
@@ -250,3 +315,13 @@ async function _dispatchOne(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap pre-check so we don't fire up the action-tag executor for every
|
||||
* specialist response — only the ones that actually contain a recognised
|
||||
* tag. Saves a workspace lookup + transaction-manager spin-up on the common
|
||||
* case (the agent just talks).
|
||||
*/
|
||||
function _hasActionTag(text: string): boolean {
|
||||
return /<\s*(?:create_file|edit_file|delete_file|read_file|list_files|list_brain|run_command|read_brain|reveal_in_explorer|open_file|glob|grep)\b/i.test(text);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Telegram mirror for the secretary agent (영숙).
|
||||
*
|
||||
* After every company turn finishes, this helper takes the CEO synthesis +
|
||||
* task list and pushes it to the user's Telegram chat — same behaviour as
|
||||
* Connect_origin's `_handleCorporatePrompt` (lines ~20620): "Secretary
|
||||
* automatically reports the round to the user." Connect AI had a Telegram
|
||||
* integration but it was wired only for *inbound* messages — the company
|
||||
* dispatcher we shipped earlier never called it.
|
||||
*
|
||||
* Design notes:
|
||||
* - **Outbound-only**: no polling, no inbound side effects. Sends a single
|
||||
* `sendMessage` per turn.
|
||||
* - **Honours user opt-in**: requires `g1nation.telegram.enabled` to be
|
||||
* true AND a stored bot token AND at least one allowed chat id. If any
|
||||
* piece is missing, `buildTelegramReporter` returns `null` and the
|
||||
* dispatcher silently skips the mirror — no log spam, no fake error.
|
||||
* - **Per-call isolation**: each reporter fetches the token freshly via
|
||||
* `context.secrets.get`, so rotating the secret in the settings UI
|
||||
* takes effect on the *next* turn without a restart.
|
||||
* - **Markdown-aware**: re-uses `TelegramHttpClient`, which already
|
||||
* truncates to Telegram's 4096-char limit and falls back to plain text
|
||||
* when the Markdown parser refuses a string.
|
||||
*/
|
||||
import * as vscode from 'vscode';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
|
||||
import { COMPANY_AGENTS } from './agents';
|
||||
import { AgentTurnOutput, CompanyState, CompanyTaskPlan } from './types';
|
||||
|
||||
/** Same key the rest of the extension uses. Defined locally so this module is dependency-free. */
|
||||
const TELEGRAM_TOKEN_SECRET_KEY = 'g1nation.telegram.botToken';
|
||||
|
||||
/** Maximum characters of the CEO report to embed verbatim in the Telegram message. */
|
||||
const REPORT_BUDGET = 2000;
|
||||
|
||||
/** Reporter contract — call once per turn, returns whether delivery succeeded. */
|
||||
export type TelegramReporter = (text: string) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Build a Telegram reporter bound to the current user's bot config. Returns
|
||||
* `null` when the user hasn't opted in (no token, no chat id, or
|
||||
* `telegram.enabled` is false) — the dispatcher then treats it as "no
|
||||
* mirror, nothing to do".
|
||||
*
|
||||
* The returned function targets the *first* allowed chat id. Users with
|
||||
* multiple chats today still get one canonical destination; broader fan-out
|
||||
* would be a feature add, not a port of origin's behaviour.
|
||||
*/
|
||||
export async function buildTelegramReporter(
|
||||
context: vscode.ExtensionContext,
|
||||
): Promise<TelegramReporter | null> {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
if (!cfg.get<boolean>('telegram.enabled', false)) return null;
|
||||
|
||||
const token = ((await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '').trim();
|
||||
if (!token) {
|
||||
logInfo('telegramReport: opt-in is on but no bot token stored; skipping mirror.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
|
||||
const chatId = allowed.find((id) => typeof id === 'number' && Number.isFinite(id) && id !== 0);
|
||||
if (!chatId) {
|
||||
logInfo('telegramReport: telegram enabled but allowedChatIds is empty; skipping mirror.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build a thin client per turn — token capture happens once, so this
|
||||
// doesn't keep a long-lived reference to the secret.
|
||||
const client = new TelegramHttpClient({ getToken: () => token });
|
||||
|
||||
return async (text: string): Promise<boolean> => {
|
||||
if (!text || !text.trim()) return false;
|
||||
try {
|
||||
await client.sendMessage({ chatId, text, parseMode: 'Markdown' });
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
// Fallback to plain text — Telegram rejects malformed Markdown
|
||||
// (unbalanced asterisks, etc.). The origin does the same thing
|
||||
// in `sendTelegramLong`, so behaviour stays familiar.
|
||||
try {
|
||||
const plain = text.replace(/[*_`[\]]/g, '');
|
||||
await client.sendMessage({ chatId, text: plain, parseMode: null });
|
||||
return true;
|
||||
} catch (e2: any) {
|
||||
logError('telegramReport: mirror failed in both modes.', {
|
||||
primary: e?.message ?? String(e),
|
||||
fallback: e2?.message ?? String(e2),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the per-turn Telegram message. Format mirrors Connect_origin's
|
||||
* Secretary report so users moving between the two products see consistent
|
||||
* notifications: header + brief + agent list + CEO synthesis + session id.
|
||||
*
|
||||
* The function is pure (no I/O) and exported separately so it can be unit-
|
||||
* tested without touching VS Code APIs.
|
||||
*/
|
||||
export function formatCompanyTelegramReport(opts: {
|
||||
state: CompanyState;
|
||||
userPrompt: string;
|
||||
plan: CompanyTaskPlan;
|
||||
outputs: AgentTurnOutput[];
|
||||
report: string;
|
||||
sessionTimestamp: string;
|
||||
}): string {
|
||||
const company = opts.state.companyName || '1인 기업';
|
||||
const header = `*📱 ${company} — 작업 라운드 보고*`;
|
||||
const cmdLine = `*명령:* ${opts.userPrompt.slice(0, 200)}`;
|
||||
const brief = opts.plan.brief ? `\n\n*브리프:* ${opts.plan.brief}` : '';
|
||||
const agentsLine = opts.plan.tasks.length > 0
|
||||
? '\n\n*완료한 에이전트:*\n' + opts.plan.tasks.map((t) => {
|
||||
const def = COMPANY_AGENTS[t.agent];
|
||||
const ranOk = opts.outputs.find((o) => o.agentId === t.agent && !o.error);
|
||||
const mark = ranOk ? '✅' : '⚠️';
|
||||
return `• ${mark} ${def?.emoji ?? ''} ${def?.name ?? t.agent}`;
|
||||
}).join('\n')
|
||||
: '';
|
||||
const reportBody = opts.report.trim()
|
||||
? `\n\n${opts.report.trim().slice(0, REPORT_BUDGET)}`
|
||||
: '';
|
||||
const sessionLine = `\n\n_세션: ${opts.sessionTimestamp}_`;
|
||||
return `${header}\n\n${cmdLine}${brief}${agentsLine}${reportBody}${sessionLine}`;
|
||||
}
|
||||
Reference in New Issue
Block a user