Skip to content
13 changes: 13 additions & 0 deletions src/ai-providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,23 @@ export type ProviderEvent =

// ==================== Types ====================

/** A tool the AI invoked during this generation. Captured by AgentCenter
* as `tool_use` events stream through the pipeline. Used by AgentWork's
* outputGate to detect intent-signal tools like `notify_user`. */
export interface ToolCallSummary {
id: string
name: string
input: unknown
}

export interface ProviderResult {
text: string
media: MediaAttachment[]
mediaUrls?: string[]
/** Tool calls observed during this generation, in invocation order.
* AgentCenter populates this when it synthesizes the final done event;
* individual providers don't need to fill it themselves. */
toolCalls?: ReadonlyArray<ToolCallSummary>
}

// ==================== GenerateOpts ====================
Expand Down
26 changes: 18 additions & 8 deletions src/connectors/telegram/telegram-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class TelegramPlugin implements Plugin {
private agentSdkConfig: AgentSdkConfig
private bot: Bot | null = null
private connectorCenter: ConnectorCenter | null = null
private ctx: EngineContext | null = null
private merger: MediaGroupMerger | null = null
private unregisterConnector?: () => void
private unsubscribeNotifications?: () => void
Expand All @@ -50,6 +51,7 @@ export class TelegramPlugin implements Plugin {
}

async start(engineCtx: EngineContext) {
this.ctx = engineCtx
this.connectorCenter = engineCtx.connectorCenter
this.webPort = engineCtx.config.connectors.web.port

Expand Down Expand Up @@ -252,7 +254,21 @@ export class TelegramPlugin implements Plugin {
// Otherwise we don't ping; the user can pull via /notifications.
this.unsubscribeNotifications = engineCtx.notificationsStore.onAppended((entry) => {
const last = engineCtx.connectorCenter.getLastInteraction()
if (last?.channel !== 'telegram') return

// Surface logic:
// 1. If this is an automated system push (heartbeat, cron), send it unless
// the user is actively using another channel right now (within 5m).
// 2. Otherwise (manual sends, task replies), only surface if Telegram
// was the last channel the user interacted with.
const isAutomated = entry.source === 'heartbeat' || entry.source === 'cron'
const isOtherRecentlyActive = last && last.channel !== 'telegram' && (Date.now() - last.ts < 300_000)

if (isAutomated) {
if (isOtherRecentlyActive) return
} else {
if (last?.channel !== 'telegram') return
}

telegramConnector
.send({ kind: 'notification', text: entry.text, media: entry.media, source: entry.source })
.catch((err) => console.warn('telegram: notification surface failed:', err))
Expand Down Expand Up @@ -355,13 +371,7 @@ export class TelegramPlugin implements Plugin {
const session = await this.getSession(userId)
await this.sendReply(chatId, '> Compacting session...')

const result = await forceCompact(
session,
async (summarizePrompt) => {
const r = await askAgentSdk(summarizePrompt, { ...this.agentSdkConfig, maxTurns: 1 })
return r.text
},
)
const result = await this.ctx!.agentCenter.forceCompact(session)

if (!result) {
await this.sendReply(chatId, 'Session is empty, nothing to compact.')
Expand Down
21 changes: 19 additions & 2 deletions src/core/agent-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@
*/

import type { AskOptions, ProviderResult, ProviderEvent, GenerateOpts } from './ai-provider-manager.js'
import type { ToolCallSummary } from '../ai-providers/types.js'
import type { ResolvedProfile } from './config.js'
import { GenerateRouter, StreamableResult } from './ai-provider-manager.js'
import { resolveProfile, resolveCredential } from './config.js'
import { profileToCredential } from './credential-inference.js'
import type { ISessionStore, ContentBlock } from './session.js'
import type { CompactionConfig } from './compaction.js'
import { compactIfNeeded } from './compaction.js'
import { compactIfNeeded, forceCompact } from './compaction.js'
import type { MediaAttachment } from './types.js'
import { extractMediaFromToolResultContent } from './media.js'
import { persistMedia } from './media-store.js'
Expand Down Expand Up @@ -90,6 +91,15 @@ export class AgentCenter {
return new StreamableResult(this._generate(prompt, session, opts))
}

/** Force a full compaction (summarization) of the session. */
async forceCompact(session: ISessionStore, opts?: AskOptions): Promise<{ preTokens: number } | null> {
const { provider } = await this.router.resolve(opts?.profileSlug)
return forceCompact(session, async (summarizePrompt) => {
const result = await provider.ask(summarizePrompt)
return result.text
})
}

// ==================== Pipeline ====================

private async *_generate(
Expand Down Expand Up @@ -131,6 +141,11 @@ export class AgentCenter {
let currentAssistantBlocks: ContentBlock[] = []
let currentUserBlocks: ContentBlock[] = []
let finalResult: ProviderResult | null = null
// Tool calls observed during this generation, captured for the final
// done event so AgentWork (and any other consumer awaiting the
// ProviderResult) can inspect what the AI invoked without having to
// re-stream the events themselves.
const toolCalls: ToolCallSummary[] = []

for await (const event of source) {
switch (event.type) {
Expand All @@ -143,6 +158,7 @@ export class AgentCenter {
// Unified logging — all providers get this now
logToolCall(event.name, event.input)
this.toolCallLog?.start(event.id, event.name, event.input, session.id)
toolCalls.push({ id: event.id, name: event.name, input: event.input })
currentAssistantBlocks.push({
type: 'tool_use',
id: event.id,
Expand Down Expand Up @@ -227,14 +243,15 @@ export class AgentCenter {
]
await session.appendAssistant(finalBlocks, provider.providerTag)

// 9. Yield done with merged media
// 9. Yield done with merged media + observed tool calls
const mediaUrls = mediaBlocks.map(b => (b as { type: 'image'; url: string }).url)
yield {
type: 'done',
result: {
text: finalResult.text,
media: allMedia,
mediaUrls,
toolCalls,
},
}
}
Expand Down
Loading