mirror of
https://github.com/humanlayer/12-factor-agents.git
synced 2025-08-20 18:59:53 +03:00
updates to some things
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"baml": "^0.0.0",
|
||||
"express": "^5.1.0",
|
||||
"humanlayer": "^0.7.7",
|
||||
"tsx": "^4.15.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
@@ -2224,6 +2225,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/humanlayer": {
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/humanlayer/-/humanlayer-0.7.7.tgz",
|
||||
"integrity": "sha512-6pk+EvyHDPUJwDex5tGwv/11tn1ZeanRUr2hrDIF3F7zRqB6fstRilpRN5Glc4s+WdnzqCvaBsAQOHk9DyLDww==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dependencies": {
|
||||
"baml": "^0.0.0",
|
||||
"express": "^5.1.0",
|
||||
"humanlayer": "^0.7.7",
|
||||
"tsx": "^4.15.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// cli.ts lets you invoke the agent loop from the command line
|
||||
|
||||
import { humanlayer } from "humanlayer";
|
||||
import { agentLoop, Thread, Event } from "../src/agent";
|
||||
|
||||
|
||||
|
||||
export async function cli() {
|
||||
// Get command line arguments, skipping the first two (node and script name)
|
||||
const args = process.argv.slice(2);
|
||||
@@ -20,23 +19,31 @@ export async function cli() {
|
||||
const thread = new Thread([{ type: "user_input", data: message }]);
|
||||
|
||||
// Run the agent loop with the thread
|
||||
const result = await agentLoop(thread);
|
||||
let lastEvent = result.events.slice(-1)[0];
|
||||
let newThread = await agentLoop(thread);
|
||||
let lastEvent = newThread.events.slice(-1)[0];
|
||||
|
||||
while (lastEvent.data.intent === "request_more_information") {
|
||||
const message = await askHuman(lastEvent.data.message);
|
||||
thread.events.push({ type: "human_response", data: message });
|
||||
const result = await agentLoop(thread);
|
||||
lastEvent = result.events.slice(-1)[0];
|
||||
while (lastEvent.data.intent !== "done_for_now") {
|
||||
const responseEvent = await askHuman(lastEvent);
|
||||
thread.events.push(responseEvent);
|
||||
newThread = await agentLoop(thread);
|
||||
lastEvent = newThread.events.slice(-1)[0];
|
||||
}
|
||||
|
||||
// print the final result
|
||||
// optional - you could loop here too
|
||||
// optional - you could loop here too
|
||||
console.log(lastEvent.data.message);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function askHuman(message: string) {
|
||||
async function askHuman(lastEvent: Event): Promise<Event> {
|
||||
if (process.env.HUMANLAYER_API_KEY) {
|
||||
return await askHumanEmail(lastEvent);
|
||||
} else {
|
||||
return await askHumanCLI(lastEvent.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function askHumanCLI(message: string): Promise<Event> {
|
||||
const readline = require('readline').createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
@@ -44,7 +51,66 @@ async function askHuman(message: string) {
|
||||
|
||||
return new Promise((resolve) => {
|
||||
readline.question(`${message}\n> `, (answer: string) => {
|
||||
resolve(answer);
|
||||
resolve({ type: "human_response", data: answer });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function askHumanEmail(lastEvent: Event): Promise<Event> {
|
||||
if (!process.env.HUMANLAYER_EMAIL) {
|
||||
throw new Error("missing or invalid parameters: HUMANLAYER_EMAIL");
|
||||
}
|
||||
const hl = humanlayer({ //reads apiKey from env
|
||||
// name of this agent
|
||||
runId: "12fa-cli-agent",
|
||||
verbose: true,
|
||||
contactChannel: {
|
||||
// agent should request permission via email
|
||||
email: {
|
||||
address: process.env.HUMANLAYER_EMAIL,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (lastEvent.data.intent === "request_more_information") {
|
||||
// fetch response synchronously - this will block until reply
|
||||
const response = await hl.fetchHumanResponse({
|
||||
spec: {
|
||||
msg: lastEvent.data.message
|
||||
}
|
||||
})
|
||||
return {
|
||||
"type": "tool_response",
|
||||
"data": response
|
||||
}
|
||||
}
|
||||
|
||||
if (lastEvent.data.intent === "divide") {
|
||||
// fetch approval synchronously - this will block until reply
|
||||
const response = await hl.fetchHumanApproval({
|
||||
spec: {
|
||||
fn: "divide",
|
||||
kwargs: {
|
||||
a: lastEvent.data.a,
|
||||
b: lastEvent.data.b
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (response.approved) {
|
||||
const result = lastEvent.data.a / lastEvent.data.b;
|
||||
console.log("tool_response", result);
|
||||
return {
|
||||
"type": "tool_response",
|
||||
"data": result
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
"type": "tool_response",
|
||||
"data": `user denied operation ${lastEvent.data.intent}
|
||||
with feedback: ${response.comment}`
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error(`unknown tool: ${lastEvent.data.intent}`)
|
||||
}
|
||||
@@ -29,6 +29,24 @@ sections:
|
||||
title: "Chapter 0 - Hello World"
|
||||
text: "Let's start with a basic TypeScript setup and a hello world program."
|
||||
steps:
|
||||
- text: |
|
||||
This guide is written in TypeScript (yes, a python version is coming soon)
|
||||
|
||||
There are many checkpoints between the every file edit in theworkshop steps,
|
||||
so even if you aren't super familiar with typescript,
|
||||
you should be able to keep up and run each example.
|
||||
|
||||
To run this guide, you'll need a relatively recent version of nodejs and npm installed
|
||||
|
||||
You can use whatever nodejs version manager you want, [homebrew](https://formulae.brew.sh/formula/node) is fine
|
||||
|
||||
command:
|
||||
brew install node@20
|
||||
results:
|
||||
- text: "You should see the node version"
|
||||
code: |
|
||||
node --version
|
||||
|
||||
- text: "Copy initial package.json"
|
||||
file: {src: ./walkthrough/00-package.json, dest: package.json}
|
||||
- text: "Install dependencies"
|
||||
@@ -55,7 +73,9 @@ sections:
|
||||
title: "Chapter 1 - CLI and Agent Loop"
|
||||
text: "Now let's add BAML and create our first agent with a CLI interface."
|
||||
steps:
|
||||
- text: "Install BAML"
|
||||
- text: |
|
||||
First, we'll need to install [BAML](https://github.com/boundaryml/baml)
|
||||
which is a tool for prompting and structured outputs.
|
||||
command: |
|
||||
npm i baml
|
||||
incremental: true
|
||||
@@ -67,7 +87,7 @@ sections:
|
||||
command: |
|
||||
rm baml_src/resume.baml
|
||||
incremental: true
|
||||
- text: "Add our starter agent"
|
||||
- text: "Add our starter agent, a single baml prompt that we'll build on"
|
||||
file: {src: ./walkthrough/01-agent.baml, dest: baml_src/agent.baml}
|
||||
- text: "Generate BAML client code"
|
||||
command: |
|
||||
@@ -82,9 +102,33 @@ sections:
|
||||
file: {src: ./walkthrough/01-index.ts, dest: src/index.ts}
|
||||
- text: "Add the agent implementation"
|
||||
file: {src: ./walkthrough/01-agent.ts, dest: src/agent.ts}
|
||||
- text: |
|
||||
The the BAML code is configured to use OPENAI_API_KEY by default
|
||||
|
||||
As you're testing, you can change the model / provider to something else
|
||||
as you please
|
||||
|
||||
client "openai/gpt-4o"
|
||||
|
||||
[Docs on baml clients can be found here](https://docs.boundaryml.com/guide/baml-basics/switching-llms)
|
||||
|
||||
For example, you can configure [gemini](https://docs.boundaryml.com/ref/llm-client-providers/google-ai-gemini)
|
||||
or [anthropic](https://docs.boundaryml.com/ref/llm-client-providers/anthropic) as your model provider.
|
||||
|
||||
If you want to run the example with no changes, you can set the OPENAI_API_KEY env var to any valid openai key.
|
||||
|
||||
command: |
|
||||
export OPENAI_API_KEY=...
|
||||
- text: "Try it out"
|
||||
command: |
|
||||
npx tsx src/index.ts hello
|
||||
results:
|
||||
- text: you should see a familiar response from the model
|
||||
code: |
|
||||
{
|
||||
intent: 'done_for_now',
|
||||
message: 'Hello! How can I assist you today?'
|
||||
}
|
||||
|
||||
- name: calculator-tools
|
||||
title: "Chapter 2 - Add Calculator Tools"
|
||||
@@ -346,13 +390,35 @@ sections:
|
||||
command: |
|
||||
npm install humanlayer
|
||||
incremental: true
|
||||
- text: "Update agent with HumanLayer integration"
|
||||
file: {src: ./walkthrough/11-agent.ts, dest: src/agent.ts}
|
||||
- text: "Update CLI with HumanLayer support"
|
||||
file: {src: ./walkthrough/11-cli.ts, dest: src/cli.ts}
|
||||
- text: "Run the CLI"
|
||||
command: |
|
||||
npx tsx src/index.ts 'can divide 4 by 5'
|
||||
npx tsx src/index.ts 'can you divide 4 by 5'
|
||||
results:
|
||||
- text: "The last line of your program should mention human review step"
|
||||
code: |
|
||||
nextStep { intent: 'divide', a: 4, b: 5 }
|
||||
HumanLayer: Requested human approval from HumanLayer cloud
|
||||
- text: |
|
||||
go ahead and respond to the email with some feedback:
|
||||
|
||||

|
||||
- text: |
|
||||
you should get another email with an updated attempt based on your feedback!
|
||||
|
||||
You can go ahead and approve this one:
|
||||
|
||||

|
||||
results:
|
||||
- text: and your final output will look like
|
||||
code: |
|
||||
nextStep {
|
||||
intent: 'done_for_now',
|
||||
message: 'The division of 4 by 5 is 0.8. If you have any other calculations or questions, feel free to ask!'
|
||||
}
|
||||
The division of 4 by 5 is 0.8. If you have any other calculations or questions, feel free to ask!
|
||||
|
||||
|
||||
- name: humanlayer-webhook
|
||||
title: "Chapter 12 - HumanLayer Webhook Integration"
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { AddTool, SubtractTool, DivideTool, MultiplyTool, b } from "../baml_client";
|
||||
|
||||
export interface Event {
|
||||
type: string
|
||||
data: any;
|
||||
}
|
||||
|
||||
export class Thread {
|
||||
events: Event[] = [];
|
||||
|
||||
constructor(events: Event[]) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
serializeForLLM() {
|
||||
return this.events.map(e => this.serializeOneEvent(e)).join("\n");
|
||||
}
|
||||
|
||||
trimLeadingWhitespace(s: string) {
|
||||
return s.replace(/^[ \t]+/gm, '');
|
||||
}
|
||||
|
||||
serializeOneEvent(e: Event) {
|
||||
return this.trimLeadingWhitespace(`
|
||||
<${e.data?.intent || e.type}>
|
||||
${
|
||||
typeof e.data !== 'object' ? e.data :
|
||||
Object.keys(e.data).filter(k => k !== 'intent').map(k => `${k}: ${e.data[k]}`).join("\n")}
|
||||
</${e.data?.intent || e.type}>
|
||||
`)
|
||||
}
|
||||
|
||||
awaitingHumanResponse(): boolean {
|
||||
const lastEvent = this.events[this.events.length - 1];
|
||||
return ['request_more_information', 'done_for_now'].includes(lastEvent.data.intent);
|
||||
}
|
||||
|
||||
awaitingHumanApproval(): boolean {
|
||||
const lastEvent = this.events[this.events.length - 1];
|
||||
return lastEvent.data.intent === 'divide';
|
||||
}
|
||||
}
|
||||
|
||||
export type CalculatorTool = AddTool | SubtractTool | MultiplyTool | DivideTool;
|
||||
|
||||
export async function handleNextStep(nextStep: CalculatorTool, thread: Thread): Promise<Thread> {
|
||||
let result: number;
|
||||
switch (nextStep.intent) {
|
||||
case "add":
|
||||
result = nextStep.a + nextStep.b;
|
||||
console.log("tool_response", result);
|
||||
thread.events.push({
|
||||
"type": "tool_response",
|
||||
"data": result
|
||||
});
|
||||
return thread;
|
||||
case "subtract":
|
||||
result = nextStep.a - nextStep.b;
|
||||
console.log("tool_response", result);
|
||||
thread.events.push({
|
||||
"type": "tool_response",
|
||||
"data": result
|
||||
});
|
||||
return thread;
|
||||
case "multiply":
|
||||
result = nextStep.a * nextStep.b;
|
||||
console.log("tool_response", result);
|
||||
thread.events.push({
|
||||
"type": "tool_response",
|
||||
"data": result
|
||||
});
|
||||
return thread;
|
||||
case "divide":
|
||||
result = nextStep.a / nextStep.b;
|
||||
console.log("tool_response", result);
|
||||
thread.events.push({
|
||||
"type": "tool_response",
|
||||
"data": result
|
||||
});
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
|
||||
export async function agentLoop(thread: Thread): Promise<Thread> {
|
||||
|
||||
while (true) {
|
||||
const nextStep = await b.DetermineNextStep(thread.serializeForLLM());
|
||||
console.log("nextStep", nextStep);
|
||||
|
||||
thread.events.push({
|
||||
"type": "tool_call",
|
||||
"data": nextStep
|
||||
});
|
||||
|
||||
switch (nextStep.intent) {
|
||||
case "done_for_now":
|
||||
case "request_more_information":
|
||||
// response to human, return the next step object
|
||||
return thread;
|
||||
case "divide":
|
||||
// divide is scary, return it for human approval
|
||||
return thread;
|
||||
case "add":
|
||||
case "subtract":
|
||||
case "multiply":
|
||||
thread = await handleNextStep(nextStep, thread);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,18 +22,11 @@ export async function cli() {
|
||||
let newThread = await agentLoop(thread);
|
||||
let lastEvent = newThread.events.slice(-1)[0];
|
||||
|
||||
let needsResponse =
|
||||
newThread.awaitingHumanResponse() ||
|
||||
newThread.awaitingHumanApproval();
|
||||
|
||||
while (needsResponse) {
|
||||
lastEvent = newThread.events.slice(-1)[0];
|
||||
while (lastEvent.data.intent !== "done_for_now") {
|
||||
const responseEvent = await askHuman(lastEvent);
|
||||
thread.events.push(responseEvent);
|
||||
newThread = await agentLoop(thread);
|
||||
// determine if we should loop or if we're done
|
||||
needsResponse = newThread.awaitingHumanResponse()
|
||||
|| newThread.awaitingHumanApproval();
|
||||
lastEvent = newThread.events.slice(-1)[0];
|
||||
}
|
||||
|
||||
// print the final result
|
||||
@@ -69,7 +62,8 @@ export async function askHumanEmail(lastEvent: Event): Promise<Event> {
|
||||
}
|
||||
const hl = humanlayer({ //reads apiKey from env
|
||||
// name of this agent
|
||||
runId: "cli-agent",
|
||||
runId: "12fa-cli-agent",
|
||||
verbose: true,
|
||||
contactChannel: {
|
||||
// agent should request permission via email
|
||||
email: {
|
||||
@@ -79,6 +73,7 @@ export async function askHumanEmail(lastEvent: Event): Promise<Event> {
|
||||
})
|
||||
|
||||
if (lastEvent.data.intent === "request_more_information") {
|
||||
// fetch response synchronously - this will block until reply
|
||||
const response = await hl.fetchHumanResponse({
|
||||
spec: {
|
||||
msg: lastEvent.data.message
|
||||
@@ -91,7 +86,7 @@ export async function askHumanEmail(lastEvent: Event): Promise<Event> {
|
||||
}
|
||||
|
||||
if (lastEvent.data.intent === "divide") {
|
||||
// fetch approval synchronously
|
||||
// fetch approval synchronously - this will block until reply
|
||||
const response = await hl.fetchHumanApproval({
|
||||
spec: {
|
||||
fn: "divide",
|
||||
@@ -112,7 +107,8 @@ export async function askHumanEmail(lastEvent: Event): Promise<Event> {
|
||||
} else {
|
||||
return {
|
||||
"type": "tool_response",
|
||||
"data": `user denied operation ${lastEvent.data.intent}`
|
||||
"data": `user denied operation ${lastEvent.data.intent}
|
||||
with feedback: ${response.comment}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 178 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 182 KiB |
@@ -22,22 +22,15 @@ export async function cli() {
|
||||
let newThread = await agentLoop(thread);
|
||||
let lastEvent = newThread.events.slice(-1)[0];
|
||||
|
||||
let needsResponse =
|
||||
newThread.awaitingHumanResponse() ||
|
||||
newThread.awaitingHumanApproval();
|
||||
|
||||
while (needsResponse) {
|
||||
lastEvent = newThread.events.slice(-1)[0];
|
||||
while (lastEvent.data.intent !== "done_for_now") {
|
||||
const responseEvent = await askHuman(lastEvent);
|
||||
thread.events.push(responseEvent);
|
||||
newThread = await agentLoop(thread);
|
||||
// determine if we should loop or if we're done
|
||||
needsResponse = newThread.awaitingHumanResponse()
|
||||
|| newThread.awaitingHumanApproval();
|
||||
lastEvent = newThread.events.slice(-1)[0];
|
||||
}
|
||||
|
||||
// print the final result
|
||||
// optional - you could loop here too
|
||||
// optional - you could loop here too
|
||||
console.log(lastEvent.data.message);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -63,29 +56,31 @@ async function askHumanCLI(message: string): Promise<Event> {
|
||||
});
|
||||
}
|
||||
|
||||
async function askHumanEmail(lastEvent: Event): Promise<Event> {
|
||||
export async function askHumanEmail(lastEvent: Event): Promise<Event> {
|
||||
if (!process.env.HUMANLAYER_EMAIL) {
|
||||
throw new Error("missing or invalid parameters: HUMANLAYER_EMAIL");
|
||||
}
|
||||
const hl = humanlayer({ //reads apiKey from env
|
||||
// name of this agent
|
||||
runId: "cli-agent",
|
||||
runId: "12fa-cli-agent",
|
||||
verbose: true,
|
||||
contactChannel: {
|
||||
// agent should request permission via email
|
||||
email: {
|
||||
address: process.env.HUMANLAYER_EMAIL,
|
||||
// custom email body - jinja
|
||||
template: `
|
||||
agent {{ event.run_id }} is requesting approval for {{event.spec.fn}}
|
||||
with args: {{event.spec.kwargs}}
|
||||
<br><br>
|
||||
reply to this email to approve
|
||||
agent {{ event.run_id }} is requesting approval for {{event.spec.fn}}
|
||||
with args: {{event.spec.kwargs}}
|
||||
<br><br>
|
||||
reply to this email to approve
|
||||
`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (lastEvent.data.intent === "request_more_information") {
|
||||
// fetch response synchronously - this will block until reply
|
||||
const response = await hl.fetchHumanResponse({
|
||||
spec: {
|
||||
msg: lastEvent.data.message
|
||||
@@ -98,7 +93,7 @@ async function askHumanEmail(lastEvent: Event): Promise<Event> {
|
||||
}
|
||||
|
||||
if (lastEvent.data.intent === "divide") {
|
||||
// fetch approval synchronously
|
||||
// fetch approval synchronously - this will block until reply
|
||||
const response = await hl.fetchHumanApproval({
|
||||
spec: {
|
||||
fn: "divide",
|
||||
|
||||
Reference in New Issue
Block a user