Skip to content

Commit cdd0480

Browse files
authored
Changelog Agent (#333)
* wip - changelog agent * scaffolding * scaffolding * re-add * url scheme * readme * setup * cleanup * cleanup * load PR content * ignore agents in build * rename * install stuff * automated changelogs * remove internal notes
1 parent a55108b commit cdd0480

File tree

16 files changed

+2199
-3
lines changed

16 files changed

+2199
-3
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
node_modules
44
.DS_Store
55
.env.local
6-
public/sitemap*.xml
6+
public/sitemap*.xml
7+
.env

agents/changelog/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
OPENAI_API_KEY="abc123"
2+
GITHUB_TOKEN="abc123"
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { WrappedAgent } from "../classes/wrappedAgent";
2+
import type { Config } from "../classes/config";
3+
import type { Logger } from "../classes/logger";
4+
import { getNewCommitsTool } from "../tools/getNewCommitsAndPRs";
5+
import { readFileTool } from "../tools/readFile";
6+
import { writeFileTool } from "../tools/writeFile";
7+
8+
export class ChangelogAgent extends WrappedAgent {
9+
constructor(config: Config, logger: Logger) {
10+
const systemPrompt = `
11+
You are a helpful assistant that writes changelogs for the Arcade.dev software projects.
12+
13+
Your goal is to load all the new git commits and pull requests from provided Github repositories since the last entry in the changelog.md file, and produce a list of the changes for our customers. You will use the GitHub API to get the changes and pull requests for all of the relevant projects.
14+
15+
There are 5 possible categories of changes:
16+
- Frameworks
17+
- Toolkits
18+
- CLI and TDK
19+
- Platform and Engine
20+
- Misc
21+
22+
There are 4 possible types of changes, which each have an emoji associated with them:
23+
- 🚀 Feature
24+
- 🐛 Bugfix
25+
- 📝 Documentation
26+
- 🔧 Maintenance
27+
28+
The steps to follow are:
29+
1. Load the changelog.mdx file and note the date of the most recent entry.
30+
2. Load all new commits since the most recent entry in the changelog.mdx file from the provided Github repositories.
31+
3. Categorize the changes into the 5 categories and 3 types. If the change is not in one of the categories, it should be categorized as "Misc". Ignore small changes that are not worth mentioning - use your judgement.
32+
4. Update the changelog.mdx file with the new changes. The changelog should be in the same format as the changelog.mdx file. Do not include any other text in the changelog.mdx file.
33+
34+
Report the steps you took to update the changelog when complete, or any errors you encountered.
35+
`;
36+
37+
const tools = [
38+
readFileTool(config, logger),
39+
writeFileTool(config, logger),
40+
getNewCommitsTool(config, logger),
41+
];
42+
43+
super("ChangelogAgent", systemPrompt, tools, [], config, logger);
44+
}
45+
46+
async generate(changelogPath: string, repositories: string[]) {
47+
this.logger.startSpan(
48+
`Generating changelog from changes in ${repositories.join(", ")}...`,
49+
);
50+
51+
const result = await this.run(
52+
`
53+
Today is ${new Date().toISOString().split("T")[0]}.
54+
The full path to the changelog.md that you will be updating is \`${changelogPath}\`.
55+
The Github repositories to load commits from are: ${repositories.join(", ")}
56+
`,
57+
);
58+
59+
this.logger.endSpan(result.finalOutput);
60+
}
61+
}

agents/changelog/classes/config.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { LogLevel } from "./logger";
2+
3+
export class Config {
4+
public readonly openai_api_key: string;
5+
public readonly openai_base_url: string | undefined;
6+
public readonly openai_model: string | undefined;
7+
public readonly directory: string;
8+
public readonly log_level: LogLevel;
9+
public readonly log_color: boolean = true;
10+
public readonly log_timestamps: boolean = true;
11+
public readonly github_token: string;
12+
13+
constructor(options: Record<string, string | boolean | undefined>) {
14+
const openai_api_key = process.env.OPENAI_API_KEY || options.openai_api_key;
15+
if (typeof openai_api_key === "string") {
16+
this.openai_api_key = openai_api_key;
17+
} else {
18+
throw new Error("OpenAI API key is required");
19+
}
20+
21+
const openai_base_url =
22+
process.env.OPENAI_BASE_URL || options.openai_base_url;
23+
if (typeof openai_base_url === "string") {
24+
this.openai_base_url = openai_base_url;
25+
}
26+
27+
const openai_model = process.env.OPENAI_MODEL || options.openai_model;
28+
if (typeof openai_model === "string") {
29+
this.openai_model = openai_model;
30+
}
31+
32+
const directory = options.directory;
33+
if (typeof directory === "string") {
34+
this.directory = directory;
35+
} else {
36+
throw new Error("The directory to consider is required");
37+
}
38+
39+
this.log_level = (options.log_level as LogLevel) || LogLevel.INFO;
40+
41+
if (options.log_color === true || options.log_color === false) {
42+
this.log_color = options.log_color;
43+
}
44+
45+
if (options.log_timestamps === true || options.log_timestamps === false) {
46+
this.log_timestamps = options.log_timestamps;
47+
}
48+
49+
const github_token = process.env.GITHUB_TOKEN || options.github_token;
50+
if (typeof github_token === "string") {
51+
this.github_token = github_token;
52+
}
53+
}
54+
}

agents/changelog/classes/logger.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import chalk from "chalk";
2+
import ora, { type Ora } from "ora";
3+
4+
import type { Config } from "./config";
5+
6+
export enum LogLevel {
7+
DEBUG = "debug", // eslint-disable-line no-unused-vars
8+
INFO = "info", // eslint-disable-line no-unused-vars
9+
WARN = "warn", // eslint-disable-line no-unused-vars
10+
ERROR = "error", // eslint-disable-line no-unused-vars
11+
}
12+
13+
export class Logger {
14+
private level: LogLevel;
15+
private color: boolean;
16+
private includeTimestamps: boolean;
17+
private spanStartTime: number | undefined = undefined;
18+
private spinner: Ora | undefined = undefined;
19+
private toolCallCount: number = 0;
20+
private updateInterval: NodeJS.Timeout | undefined = undefined;
21+
22+
constructor(config: Config) {
23+
this.includeTimestamps = config.log_timestamps;
24+
this.level = config.log_level;
25+
this.color = config.log_color;
26+
}
27+
28+
private getTimestamp() {
29+
const timestamp = new Date().toLocaleTimeString("en-US", {
30+
hour12: false,
31+
hour: "2-digit",
32+
minute: "2-digit",
33+
second: "2-digit",
34+
});
35+
36+
return this.includeTimestamps
37+
? this.color
38+
? chalk.gray(`[${timestamp}]`)
39+
: `[${timestamp}]`
40+
: "";
41+
}
42+
43+
private getSpanMarker() {
44+
return this.spanStartTime !== undefined ? " ├─" : "";
45+
}
46+
47+
private getDuration() {
48+
return Math.round((Date.now() - (this.spanStartTime ?? Date.now())) / 1000);
49+
}
50+
51+
private getToolCallStats() {
52+
const duration = this.getDuration();
53+
const toolCallText = ` 🕝 duration: ${duration}s | 🛠️ tool calls: ${this.toolCallCount}`;
54+
return this.color ? chalk.dim(toolCallText) : toolCallText;
55+
}
56+
57+
private formatMessage(message: string, color: (text: string) => string) {
58+
return this.color ? color(message) : message;
59+
}
60+
61+
private logToConsole(
62+
message: string,
63+
level: LogLevel,
64+
color: (text: string) => string,
65+
) {
66+
const shouldSkip =
67+
(this.level === LogLevel.ERROR && level !== LogLevel.ERROR) ||
68+
(this.level === LogLevel.WARN &&
69+
(level === LogLevel.INFO || level === LogLevel.DEBUG)) ||
70+
(this.level === LogLevel.INFO && level === LogLevel.DEBUG);
71+
if (shouldSkip) return;
72+
73+
const timestamp = this.getTimestamp();
74+
const spanMarker = this.getSpanMarker();
75+
const formattedMessage = this.formatMessage(message, color);
76+
const output = `${timestamp}${spanMarker} ${formattedMessage}`;
77+
78+
if (level === LogLevel.ERROR || level === LogLevel.WARN) {
79+
console.error(output);
80+
} else if (level === LogLevel.DEBUG) {
81+
console.debug(output);
82+
} else {
83+
console.log(output);
84+
}
85+
}
86+
87+
info(message: string) {
88+
this.logToConsole(message, LogLevel.INFO, chalk.white);
89+
}
90+
91+
warn(message: string) {
92+
this.logToConsole(message, LogLevel.WARN, chalk.yellow);
93+
}
94+
95+
error(message: string) {
96+
this.logToConsole(message, LogLevel.ERROR, chalk.red);
97+
}
98+
99+
debug(message: string) {
100+
this.logToConsole(message, LogLevel.DEBUG, chalk.gray);
101+
}
102+
103+
incrementToolCalls() {
104+
this.toolCallCount++;
105+
this.updateSpanDisplay();
106+
}
107+
108+
private updateSpanDisplay() {
109+
if (!this.spinner) return;
110+
const mainMessage = this.spinner.text.split("\n")[0];
111+
this.spinner.text = `${mainMessage}\n${this.getToolCallStats()}`;
112+
}
113+
114+
startSpan(message: string) {
115+
this.info(message);
116+
this.spanStartTime = Date.now();
117+
this.toolCallCount = 0;
118+
this.spinner = ora(this.formatMessage(message, chalk.cyan)).start();
119+
120+
this.updateInterval = setInterval(() => this.updateSpanDisplay(), 1000);
121+
}
122+
123+
updateSpan(message: string, emoji: string) {
124+
if (!this.spinner) return;
125+
126+
const originalText = this.spinner.text;
127+
const timestamp = this.getTimestamp();
128+
const spanMarker = this.getSpanMarker();
129+
const formattedMessage = this.formatMessage(message, chalk.white);
130+
131+
this.spinner.stopAndPersist({
132+
text: formattedMessage,
133+
symbol: `${timestamp}${spanMarker} ${emoji}`,
134+
});
135+
136+
this.spinner.start(this.formatMessage(originalText, chalk.cyan));
137+
this.updateSpanDisplay();
138+
}
139+
140+
endSpan(message: string = "Completed with no output") {
141+
if (this.updateInterval) {
142+
clearInterval(this.updateInterval);
143+
this.updateInterval = undefined;
144+
}
145+
146+
const doneMessage = "Done!";
147+
const timestamp = this.getTimestamp();
148+
const duration = this.getDuration();
149+
150+
this.spinner?.stopAndPersist({
151+
text: this.formatMessage(`${doneMessage} (${duration}s)`, chalk.cyan),
152+
symbol: `${timestamp} ✅`,
153+
});
154+
155+
this.spinner = undefined;
156+
this.spanStartTime = undefined;
157+
this.toolCallCount = 0;
158+
159+
this.info(`\r\n${message}\r\n`);
160+
}
161+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import {
2+
Agent,
3+
type AgentInputItem,
4+
Runner,
5+
type Tool,
6+
handoff,
7+
setDefaultOpenAIClient,
8+
user,
9+
} from "@openai/agents";
10+
import OpenAI from "openai";
11+
12+
import { Config } from "./config";
13+
import type { Logger } from "./logger";
14+
15+
export abstract class WrappedAgent {
16+
readonly agent: Agent<unknown, "text">;
17+
readonly runner: Runner;
18+
history: AgentInputItem[] = [];
19+
20+
constructor(
21+
readonly name: string, // eslint-disable-line no-unused-vars
22+
readonly instructions: string, // eslint-disable-line no-unused-vars
23+
readonly tools: Tool[] | undefined, // eslint-disable-line no-unused-vars
24+
readonly handoffs: ReturnType<typeof handoff<unknown, "text">>[], // eslint-disable-line no-unused-vars
25+
readonly config: Config, // eslint-disable-line no-unused-vars
26+
readonly logger: Logger, // eslint-disable-line no-unused-vars
27+
) {
28+
// TODO: This likely isn't necessary every time we make a new agent
29+
const client = new OpenAI({
30+
apiKey: this.config.openai_api_key,
31+
baseURL: this.config.openai_base_url,
32+
});
33+
setDefaultOpenAIClient(client);
34+
35+
this.agent = new Agent<unknown, "text">({
36+
name: this.name,
37+
model: this.config.openai_model,
38+
instructions: this.instructions,
39+
tools: this.tools,
40+
handoffs: this.handoffs,
41+
});
42+
43+
this.runner = new Runner(this.agent);
44+
}
45+
46+
protected async run(prompt: string, maxTurns = 10) {
47+
this.history.push(user(prompt));
48+
49+
const result = await this.runner.run(this.agent, this.history, {
50+
maxTurns,
51+
});
52+
53+
if (result.history.length > 0) {
54+
this.history = result.history;
55+
}
56+
57+
if (result.finalOutput) {
58+
this.logger.debug(result.finalOutput);
59+
}
60+
61+
return result;
62+
}
63+
}

0 commit comments

Comments
 (0)