Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
167 changes: 167 additions & 0 deletions ts/packages/agents/markdown/src/agent/documentOperations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type {
ContentItem,
DocumentOperation,
} from "./markdownOperationSchema.js";

export function applyDocumentOperations(
content: string,
operations: DocumentOperation[],
): string {
return operations.reduce(
(updatedContent, operation) =>
applyDocumentOperation(updatedContent, operation),
content,
);
}

function applyDocumentOperation(
content: string,
operation: DocumentOperation,
): string {
switch (operation.type) {
case "insert": {
const position = clampPosition(operation.position, content.length);
return (
content.slice(0, position) +
contentItemsToText(operation.content) +
content.slice(position)
);
}
case "replace": {
const [from, to] = clampRange(
operation.from,
operation.to,
content.length,
);
return (
content.slice(0, from) +
contentItemsToText(operation.content) +
content.slice(to)
);
}
case "delete": {
const [from, to] = clampRange(
operation.from,
operation.to,
content.length,
);
return content.slice(0, from) + content.slice(to);
}
case "format":
throw new Error(
"Format operations cannot be applied to markdown text",
);
}
}

function contentItemsToText(items: ContentItem[]): string {
return items.map((item) => contentItemToText(item)).join("");
}

function contentItemToText(item: ContentItem): string {
const text = getPlainText(item);
switch (item.type) {
case "heading": {
if (/^#{1,6}\s/.test(text)) {
return ensureBlockSeparator(text);
}
const attrs = item.attrs as { level?: number } | undefined;
const requestedLevel = attrs?.level;
const level =
requestedLevel !== undefined &&
Number.isInteger(requestedLevel) &&
requestedLevel >= 1 &&
requestedLevel <= 6
? requestedLevel
: 1;
return `${"#".repeat(level)} ${text}\n\n`;
}
case "paragraph":
return ensureBlockSeparator(text);
case "bullet_list":
return serializeList(item, "-");
case "ordered_list":
return serializeList(item, "1.");
case "code_block":
return `\`\`\`\n${text}\n\`\`\`\n\n`;
case "blockquote":
return `${text
.split("\n")
.map((line) => `> ${line}`)
.join("\n")}\n\n`;
case "horizontal_rule":
return "---\n\n";
case "hard_break":
return " \n";
case "text":
return applyMarks(text, item);
default:
return text;
}
}

function getPlainText(item: ContentItem): string {
if (item.text !== undefined) {
return item.text;
}
return item.content ? item.content.map(getPlainText).join("") : "";
}

function ensureBlockSeparator(text: string): string {
return text.endsWith("\n\n") ? text : `${text}\n\n`;
}

function serializeList(item: ContentItem, marker: string): string {
const lines =
item.content?.map(
(child) => `${marker} ${getPlainText(child).trim()}`,
) ?? [];
return `${lines.join("\n")}\n\n`;
}

function applyMarks(text: string, item: ContentItem): string {
return (item.marks ?? []).reduce((markedText, mark) => {
switch (mark.type) {
case "strong":
return `**${markedText}**`;
case "em":
return `*${markedText}*`;
case "code":
return `\`${markedText}\``;
case "link": {
const attrs = mark.attrs as { href?: string } | undefined;
return attrs?.href
? `[${markedText}](${attrs.href})`
: markedText;
}
default:
return markedText;
}
}, text);
}

function clampPosition(position: number, contentLength: number): number {
if (!Number.isInteger(position) || position < 0) {
throw new Error(`Invalid document position: ${position}`);
}
return Math.min(position, contentLength);
}

function clampRange(
from: number,
to: number,
contentLength: number,
): [number, number] {
if (
!Number.isInteger(from) ||
!Number.isInteger(to) ||
from < 0 ||
to < from
) {
throw new Error(`Invalid document range: ${from}-${to}`);
}
return [Math.min(from, contentLength), Math.min(to, contentLength)];
}
52 changes: 38 additions & 14 deletions ts/packages/agents/markdown/src/agent/markdownActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ChildProcess, fork } from "child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { UICommandResult } from "./ipcTypes.js";
import { applyDocumentOperations } from "./documentOperations.js";
import registerDebug from "debug";

const debug = registerDebug("typeagent:markdown:agent");
Expand Down Expand Up @@ -379,8 +380,11 @@ async function handleStreamingMarkdownAction(
`[AGENT] Starting streaming action: ${action.actionName} (stream: ${streamId})`,
);

const agent = await createMarkdownAgent("GPT_4o");
const agent = await createMarkdownAgent("GPT_4_O");
const storage = actionContext.sessionContext.sessionStorage;
if (!storage) {
throw new Error("Markdown actions require session storage");
}

// Get current document content
const filePath = `${actionContext.sessionContext.agentContext.currentFileName}`;
Expand Down Expand Up @@ -539,7 +543,6 @@ async function handleMarkdownAction(
actionContext: ActionContext<MarkdownActionContext>,
) {
let result: ActionResult | undefined = undefined;
const agent = await createMarkdownAgent("GPT_4o");

// Accumulates the LLM token usage consumed while handling this action so
// it can be reported back to the dispatcher as "Action Tokens". The agent
Expand All @@ -549,9 +552,16 @@ async function handleMarkdownAction(
completion_tokens: 0,
total_tokens: 0,
};
agent.tokenUsage = tokenUsage;
const createAgent = async () => {
const agent = await createMarkdownAgent("GPT_4_O");
agent.tokenUsage = tokenUsage;
return agent;
};

const storage = actionContext.sessionContext.sessionStorage;
if (!storage) {
throw new Error("Markdown actions require session storage");
}

switch (action.actionName) {
case "openDocument":
Expand All @@ -571,14 +581,14 @@ async function handleMarkdownAction(
actionContext.sessionContext.agentContext.currentFileName =
newFileName;

if (!(await storage?.exists(newFileName))) {
await storage?.write(newFileName, "");
if (!(await storage.exists(newFileName))) {
await storage.write(newFileName, "");
}

if (actionContext.sessionContext.agentContext.viewProcess) {
const fullPath = await getFullMarkdownFilePath(
newFileName,
storage!,
storage,
);

actionContext.sessionContext.agentContext.viewProcess.send({
Expand All @@ -588,6 +598,10 @@ async function handleMarkdownAction(
});
}
result = createActionResult("Document opened");
result.resultEntity = {
name: newFileName,
type: ["file", "markdown"],
};
result.activityContext = {
activityName: "editingMarkdown",
description: "Editing a Markdown document",
Expand All @@ -600,6 +614,7 @@ async function handleMarkdownAction(
break;
}
case "updateDocument": {
const agent = await createAgent();
debug("Starting updateDocument action in agent process");
result = createActionResult("Updating document ...");

Expand Down Expand Up @@ -629,9 +644,9 @@ async function handleMarkdownAction(
}
} else {
// Fallback if no view process
if (await storage?.exists(filePath)) {
if (await storage.exists(filePath)) {
markdownContent =
(await storage?.read(filePath, "utf8")) || "";
(await storage.read(filePath, "utf8")) || "";
debug(
"No view process, read content from storage:",
markdownContent?.length,
Expand Down Expand Up @@ -714,9 +729,12 @@ async function handleMarkdownAction(
"Operations applied successfully via view process",
);
} else {
console.warn(
"No view process available, operations not applied",
const updatedContent = applyDocumentOperations(
markdownContent,
updateResult.operations,
);
await storage.write(filePath, updatedContent);
debug("Applied operations directly to session storage");
}
} else {
debug("[AGENT] No operations returned from LLM");
Expand All @@ -740,6 +758,7 @@ async function handleMarkdownAction(
break;
}
case "streamingUpdateDocument": {
const agent = await createAgent();
// Handle streaming AI commands - now unified with regular updateDocument flow
debug(
"Starting streamingUpdateDocument action - using standard translator flow",
Expand Down Expand Up @@ -772,9 +791,9 @@ async function handleMarkdownAction(
}
} else {
// Fallback if no view process
if (await storage?.exists(filePath)) {
if (await storage.exists(filePath)) {
markdownContent =
(await storage?.read(filePath, "utf8")) || "";
(await storage.read(filePath, "utf8")) || "";
debug(
"No view process, read content from storage:",
markdownContent?.length,
Expand Down Expand Up @@ -819,8 +838,13 @@ async function handleMarkdownAction(
"Operations applied successfully via view process",
);
} else {
console.warn(
"No view process available, operations not applied",
const updatedContent = applyDocumentOperations(
markdownContent,
updateResult.operations,
);
await storage.write(filePath, updatedContent);
debug(
"Applied streaming operations directly to session storage",
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Licensed under the MIT License.

// Document operation types for incremental updates to ProseMirror documents
// Position references should be line numbers (0-based) in the document.
// Position references are character offsets (0-based) in the markdown text.
export type DocumentOperation =
| InsertOperation
| DeleteOperation
Expand Down
2 changes: 1 addition & 1 deletion ts/packages/agents/markdown/src/agent/translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { MarkdownUpdateResult } from "./markdownOperationSchema.js";
const debug = registerDebug("typeagent:markdown:translator");

export async function createMarkdownAgent(
model: "GPT_35_TURBO" | "GPT_4" | "GPT-v" | "GPT_4o",
model: "GPT_35_TURBO" | "GPT_4" | "GPT_V" | "GPT_4_O",
) {
const packageRoot = path.join("../../");
const schemaText = await fs.promises.readFile(
Expand Down
Loading
Loading