Skip to content
Draft
21 changes: 16 additions & 5 deletions extensions/telegram-manager/src/agents/BaseAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,12 +176,18 @@ export abstract class BaseAgent extends EventEmitter {
* (SOUL.md, AGENTS.md, IDENTITY.md, USER.md, MEMORY.md). Falls back to a
* minimal default when no workspace files exist.
*
* @param task Optional task description appended as a "## Task" section.
* @param chatKey Optional per-user chat key; when provided, injects the
* last N turns of that user's conversation history so the
* agent can reference prior exchanges in its replies.
* @param task Optional task description appended as a "## Task" section.
* @param chatKey Optional per-user chat key; when provided, injects the
* last N turns of that user's conversation history so the
* agent can reference prior exchanges in its replies.
* @param extraContext Optional extra section to append (e.g. writing-style
* examples fetched from real Telegram message history).
*/
protected async buildRichSystemPrompt(task?: string, chatKey?: string): Promise<string> {
protected async buildRichSystemPrompt(
task?: string,
chatKey?: string,
extraContext?: string,
): Promise<string> {
const workspaceDir = this.storage.getAgentWorkspaceDir(this.id);
const sections: string[] = [];

Expand Down Expand Up @@ -212,6 +218,11 @@ export abstract class BaseAgent extends EventEmitter {
}
}

// Inject extra context supplied by the caller (e.g. partner's writing style).
if (extraContext) {
sections.push(extraContext);
}

if (task) {
sections.push(
`## Task\n${task}\n\n` +
Expand Down
8 changes: 6 additions & 2 deletions extensions/telegram-manager/src/agents/BotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Bot, Context } from "grammy";
import cron from "node-cron";
import { aiReply } from "../behaviors/AiReplyEngine";
import { TelegramStorage } from "../storage/TelegramStorage";
import { createWorkspaceTools } from "../tools/TelegramTools";
import {
AgentRecord,
BotCredentials,
Expand Down Expand Up @@ -271,13 +272,16 @@ export class BotAgent extends BaseAgent {
taskSession = byUsername;
}
}
// Tools scoped to this agent's workspace — cannot access other agents' files.
const workspaceTools = createWorkspaceTools(this.storage.getAgentWorkspaceDir(this.id));

if (taskSession) {
await ctx.replyWithChatAction("typing");
// Use custom system prompt if provided, otherwise build from workspace files.
const systemPrompt =
taskSession.systemPrompt ?? (await this.buildRichSystemPrompt(taskSession.task, chatKey));
try {
const reply = await aiReply(text, chatKey, systemPrompt, this.storage);
const reply = await aiReply(text, chatKey, systemPrompt, this.storage, workspaceTools);
if (reply) {
await ctx.reply(reply);
this.trackMessage("out", reply, chatId);
Expand All @@ -299,7 +303,7 @@ export class BotAgent extends BaseAgent {
const systemPrompt =
cfg.aiSystemPrompt ?? (await this.buildRichSystemPrompt(undefined, chatKey));
try {
reply = await aiReply(text, chatKey, systemPrompt, this.storage);
reply = await aiReply(text, chatKey, systemPrompt, this.storage, workspaceTools);
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
this.logger.warn(`[TG:${this.name}] auto_reply AI failed: ${errMsg}`);
Expand Down
56 changes: 52 additions & 4 deletions extensions/telegram-manager/src/agents/UserBotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { NewMessage } from "telegram/events";
import { StringSession } from "telegram/sessions";
import { aiReply } from "../behaviors/AiReplyEngine";
import { TelegramStorage } from "../storage/TelegramStorage";
import { createWorkspaceTools } from "../tools/TelegramTools";
import { AgentRecord, UserbotCredentials, BehaviorConfig, ILogger } from "../types";
import { BaseAgent } from "./BaseAgent";

Expand Down Expand Up @@ -296,12 +297,18 @@ export class UserBotAgent extends BaseAgent {
// Use a stable per-chat key so history is shared with auto_reply —
// continuous dialogue is preserved when the task session ends.
const chatKey = `${this.id}:${chatId}`;
// Fetch the partner's writing style from real Telegram history for new chats.
const storedHistory = this.storage.loadConversationHistory(chatKey);
const styleContext = storedHistory.length < 4 ? await this.fetchStyleContext(chatId) : "";
// Use custom system prompt if provided, otherwise build from workspace files.
// Pass chatKey so the prompt includes a brief recap of prior exchanges.
const systemPrompt =
taskSession.systemPrompt ?? (await this.buildRichSystemPrompt(taskSession.task, chatKey));
taskSession.systemPrompt ??
(await this.buildRichSystemPrompt(taskSession.task, chatKey, styleContext));
// Tools scoped to this agent's workspace — cannot access other agents' files.
const workspaceTools = createWorkspaceTools(this.storage.getAgentWorkspaceDir(this.id));
try {
const reply = await aiReply(text, chatKey, systemPrompt, this.storage);
const reply = await aiReply(text, chatKey, systemPrompt, this.storage, workspaceTools);
if (reply) {
await this.sendWithFloodGuard(chatId, reply, msg.id);
this.trackMessage("out", reply, chatId);
Expand Down Expand Up @@ -341,11 +348,17 @@ export class UserBotAgent extends BaseAgent {

let reply = "";
if (cfg.replyMode === "ai") {
// Fetch partner's writing style from Telegram history for new conversations.
const storedHistory = this.storage.loadConversationHistory(key);
const styleContext = storedHistory.length < 4 ? await this.fetchStyleContext(chatId) : "";
// Use configured system prompt if present, otherwise build from workspace files.
// Pass goal (if set) so the agent has a clear objective in every conversation.
const systemPrompt =
cfg.aiSystemPrompt ?? (await this.buildRichSystemPrompt(undefined, key));
cfg.aiSystemPrompt ?? (await this.buildRichSystemPrompt(cfg.goal, key, styleContext));
// Tools scoped to this agent's workspace — cannot access other agents' files.
const workspaceTools = createWorkspaceTools(this.storage.getAgentWorkspaceDir(this.id));
try {
reply = await aiReply(text, key, systemPrompt, this.storage);
reply = await aiReply(text, key, systemPrompt, this.storage, workspaceTools);
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
this.logger.warn(`[TG:${this.name}] auto_reply AI failed: ${errMsg}`);
Expand Down Expand Up @@ -561,6 +574,41 @@ export class UserBotAgent extends BaseAgent {
}
}

/**
* Fetch recent messages from a Telegram chat and build a style-context section
* describing how the conversation partner writes. Used to help the AI mimic
* the other person's tone, vocabulary, and sentence structure.
*
* Only called when the stored conversation history is short (new chat) so we
* don't redundantly re-fetch once the AI already has enough context.
*/
private async fetchStyleContext(chatId: string): Promise<string> {
if (!this.client) return "";
try {
const msgs = await this.client.getMessages(chatId, { limit: 25 });
const me = (await this.client.getMe()) as unknown as { id?: bigint | number };
const myId = me?.id?.toString();
// Collect the other person's messages only (exclude our own outbound messages).
const theirTexts = (
msgs as unknown as Array<{ message?: string; senderId?: { toString(): string } }>
)
.filter((m) => m.message && m.senderId?.toString() !== myId)
.map((m) => m.message!.trim())
.filter((t) => t.length > 0)
.slice(0, 15);
if (theirTexts.length === 0) return "";
return (
`## Conversation partner's writing style\n` +
`Study how this person writes based on their recent messages:\n` +
theirTexts.map((t) => `- "${t}"`).join("\n") +
`\n\nMimic their style: match their vocabulary, sentence length, formality level, ` +
`emoji usage, and punctuation habits. Write as naturally as they do.`
);
} catch {
return "";
}
}

private postWebhook(url: string, body: unknown) {
fetch(url, {
method: "POST",
Expand Down
98 changes: 94 additions & 4 deletions extensions/telegram-manager/src/behaviors/AiReplyEngine.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
// plugins/telegram/src/behaviors/AiReplyEngine.ts
import Anthropic from "@anthropic-ai/sdk";
import {
WorkspaceTools,
workspaceToolDefs,
ReadableFile,
WritableFile,
} from "../tools/TelegramTools";

export type ModelMessage = { role: "user" | "assistant"; content: string };

Expand Down Expand Up @@ -135,6 +141,76 @@ function resolveAdapter(): ModelAdapter {
);
}

// ─── Workspace tool-calling loop (Anthropic only) ─────────────────────────────

/**
* Run an agentic Anthropic call that allows the agent to read/write its own
* workspace files. Loops up to 5 iterations while the model issues tool calls,
* then returns the final text response.
*
* Only available when ANTHROPIC_API_KEY is set; callers must check this first.
*/
async function runAnthropicWithTools(
messages: Anthropic.MessageParam[],
systemPrompt: string,
tools: WorkspaceTools,
): Promise<string> {
const client = getAnthropicClient();
const model = process.env.TG_AI_MODEL?.trim() || "claude-3-5-sonnet-20241022";
// Work on a copy so we don't mutate the caller's history during the loop.
const msgs: Anthropic.MessageParam[] = [...messages];

for (let i = 0; i < 5; i++) {
const res = await client.messages.create({
model,
max_tokens: 2048,
system: systemPrompt,
messages: msgs,
tools: workspaceToolDefs as unknown as Anthropic.Tool[],
});

if (res.stop_reason !== "tool_use") {
// No tool calls — return the text content.
const text = res.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("")
.trim();
return text || "…";
}

// Append the assistant turn (includes both text and tool_use blocks).
msgs.push({ role: "assistant", content: res.content });

// Execute each tool call and collect results.
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of res.content) {
if (block.type !== "tool_use") continue;
let output: string;
try {
if (block.name === "read_workspace_file") {
const { filename } = block.input as { filename: ReadableFile };
output = (await tools.readFile(filename)) || "(empty)";
} else if (block.name === "write_workspace_file") {
const { filename, content } = block.input as { filename: WritableFile; content: string };
await tools.writeFile(filename, content);
output = `Updated ${filename}`;
} else {
output = `Unknown tool: ${block.name}`;
}
} catch (e) {
output = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: output });
}

// Feed results back for the next iteration.
msgs.push({ role: "user", content: toolResults });
}

return "…"; // fallback after max iterations
}

// ─── Conversation history ──────────────────────────────────────────────────────

// In-memory cache for fast access; backed by optional persistent storage.
Expand All @@ -154,6 +230,7 @@ export async function aiReply(
chatKey: string, // agentId + chatId — unique per conversation
systemPrompt = "You are a helpful Telegram assistant. Be concise and friendly.",
storage?: ConversationStorage,
workspaceTools?: WorkspaceTools,
): Promise<string> {
// Warm the in-memory cache from persistent storage on the first message
// for this chat key (e.g. after an agent restart).
Expand All @@ -168,10 +245,23 @@ export async function aiReply(
hist.push({ role: "user", content: text });
if (hist.length > MAX_HISTORY) hist.splice(0, hist.length - MAX_HISTORY);

const adapter = resolveAdapter();
// Pass chatKey as sessionKey so adapters that support it (e.g. the gateway
// adapter) can maintain per-conversation sessions on the server side.
const reply = await adapter(hist, systemPrompt, chatKey);
let reply: string;

// Use the Anthropic tool-calling path when workspace tools are provided and
// a direct Anthropic key is configured. Falls back to the generic adapter
// (gateway / OpenAI-compatible) which does not support tool calling.
if (workspaceTools && process.env.ANTHROPIC_API_KEY?.trim()) {
const msgs: Anthropic.MessageParam[] = hist.map((m) => ({
role: m.role,
content: m.content,
}));
reply = await runAnthropicWithTools(msgs, systemPrompt, workspaceTools);
} else {
const adapter = resolveAdapter();
// Pass chatKey as sessionKey so adapters that support it (e.g. the gateway
// adapter) can maintain per-conversation sessions on the server side.
reply = await adapter(hist, systemPrompt, chatKey);
}

hist.push({ role: "assistant", content: reply });
histories.set(chatKey, hist);
Expand Down
99 changes: 98 additions & 1 deletion extensions/telegram-manager/src/tools/TelegramTools.ts
Original file line number Diff line number Diff line change
@@ -1 +1,98 @@
// OpenClaw Tool API placeholder
// Workspace file tools — each agent instance is scoped to its own directory,
// preventing cross-agent file access.
import fs from "fs";
import path from "path";

// Files the agent may read.
export const READABLE_FILES = [
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
"MEMORY.md",
] as const;

// Files the agent may write (memory/context only — not behavioral instructions).
export const WRITABLE_FILES = ["MEMORY.md", "USER.md"] as const;

export type ReadableFile = (typeof READABLE_FILES)[number];
export type WritableFile = (typeof WRITABLE_FILES)[number];

export interface WorkspaceTools {
workspaceDir: string;
readFile(filename: ReadableFile): Promise<string>;
writeFile(filename: WritableFile, content: string): Promise<void>;
}

/**
* Create a WorkspaceTools instance scoped to a single agent's workspace directory.
* All read/write operations are validated and confined to this directory — an agent
* cannot reach another agent's files through these tools.
*/
export function createWorkspaceTools(workspaceDir: string): WorkspaceTools {
return {
workspaceDir,

async readFile(filename: ReadableFile): Promise<string> {
if (!(READABLE_FILES as readonly string[]).includes(filename)) return "";
try {
return await fs.promises.readFile(path.join(workspaceDir, filename), "utf-8");
} catch {
return "";
}
},

async writeFile(filename: WritableFile, content: string): Promise<void> {
if (!(WRITABLE_FILES as readonly string[]).includes(filename)) {
throw new Error(
`Write access denied for "${filename}"; allowed: ${WRITABLE_FILES.join(", ")}`,
);
}
fs.mkdirSync(workspaceDir, { recursive: true });
await fs.promises.writeFile(path.join(workspaceDir, filename), content, "utf-8");
},
};
}

/** Anthropic tool definitions for workspace file access. */
export const workspaceToolDefs = [
{
name: "read_workspace_file",
description:
"Read one of your workspace files (e.g. MEMORY.md, USER.md) to recall stored context from previous sessions.",
input_schema: {
type: "object" as const,
properties: {
filename: {
type: "string" as const,
enum: READABLE_FILES as unknown as string[],
description: "The workspace file to read.",
},
},
required: ["filename"],
},
},
{
name: "write_workspace_file",
description:
"Update MEMORY.md (to remember facts across sessions) or USER.md (to record context about the person you are talking with). Use this to persist important information so it is available in future conversations.",
input_schema: {
type: "object" as const,
properties: {
filename: {
type: "string" as const,
enum: WRITABLE_FILES as unknown as string[],
description: "Which workspace file to update (MEMORY.md or USER.md).",
},
content: {
type: "string" as const,
description: "Full new content for the file.",
},
},
required: ["filename", "content"],
},
},
] as const;
2 changes: 2 additions & 0 deletions extensions/telegram-manager/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface AutoReplyBehavior {
enabled: boolean;
replyMode: "ai" | "template";
aiSystemPrompt?: string;
/** Persistent objective for the agent across all auto-reply conversations. */
goal?: string;
triggerKeywords?: string[];
templates?: { trigger: string; response: string }[];
onlyInChats?: string[];
Expand Down
Loading