Skip to content

Commit

Permalink
Change ChatCompletion to emit AssistantMessage (#218)
Browse files Browse the repository at this point in the history
This changes the various `ChatCompletion` implementations to emit
`<AssistantMessage>`s rather than loose text. This allows callers to
easily separate `<AssistantMessage>` and `<FunctionCall>` outputs.

Preserving the append-only nature of the stream requires non-trivial
gymnastics to split the iterator consumption across multiple components,
but this technique also allows for the removal of the
`experimental_streamFunctionCallOnly` flag.
  • Loading branch information
petersalas committed Jul 28, 2023
1 parent f8c8cff commit 058c463
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 128 deletions.
2 changes: 1 addition & 1 deletion packages/ai-jsx/package.json
Expand Up @@ -4,7 +4,7 @@
"repository": "fixie-ai/ai-jsx",
"bugs": "https://github.com/fixie-ai/ai-jsx/issues",
"homepage": "https://ai-jsx.com",
"version": "0.7.0",
"version": "0.7.1",
"volta": {
"extends": "../../package.json"
},
Expand Down
34 changes: 25 additions & 9 deletions packages/ai-jsx/src/batteries/constrained-output.tsx
Expand Up @@ -11,6 +11,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
import {
AssistantMessage,
ChatCompletion,
FunctionCall,
ModelPropsWithChildren,
SystemMessage,
UserMessage,
Expand Down Expand Up @@ -318,7 +319,6 @@ export async function* JsonChatCompletionFunctionCall(

const childrenWithCompletion = (
<ChatCompletion
experimental_streamFunctionCallOnly
{...props}
functionDefinitions={{
print: {
Expand All @@ -336,28 +336,44 @@ export async function* JsonChatCompletionFunctionCall(
</ChatCompletion>
);

const frames = render(childrenWithCompletion);
const frames = render(childrenWithCompletion, { stop: (e) => e.tag === FunctionCall, map: (e) => e });
for await (const frame of frames) {
const object = JSON.parse(frame).arguments;
const functionCall = frame.find((e) => AI.isElement(e) && e.tag === FunctionCall) as
| AI.Element<AI.PropsOfComponent<typeof FunctionCall>>
| undefined;
if (!functionCall) {
continue;
}

const jsonResult = functionCall.props.args;
try {
for (const validator of validatorsAndSchema) {
validator(object);
validator(jsonResult);
}
} catch (e: any) {
continue;
}
yield JSON.stringify(object);
yield JSON.stringify(jsonResult);
}
const object = JSON.parse(await frames).arguments;

const functionCall = (await frames).find((e) => AI.isElement(e) && e.tag === FunctionCall) as
| AI.Element<AI.PropsOfComponent<typeof FunctionCall>>
| undefined;

if (functionCall === undefined) {
return null;
}

const jsonResult = functionCall.props.args;
try {
for (const validator of validatorsAndSchema) {
validator(object);
validator(jsonResult);
}
} catch (e: any) {
throw new CompletionError('The model did not produce a valid JSON object', 'runtime', {
output: JSON.stringify(object),
output: JSON.stringify(jsonResult),
validationError: e.message,
});
}
return JSON.stringify(object);
return JSON.stringify(jsonResult);
}
43 changes: 19 additions & 24 deletions packages/ai-jsx/src/batteries/use-tools.tsx
Expand Up @@ -221,7 +221,7 @@ export async function* UseTools(props: UseToolsProps, { render }: RenderContext)
/** @hidden */
export async function* UseToolsFunctionCall(
props: UseToolsProps,
{ render, memo }: ComponentContext
{ render, memo, logger }: ComponentContext
): RenderableStream {
yield AppendOnlyStream;

Expand All @@ -233,35 +233,30 @@ export async function* UseToolsFunctionCall(
yield modelResponse;
}

const renderResult = await render(modelResponse, { stop: (el) => el.tag == FunctionCall });
const renderResult = await render(modelResponse, {
stop: (el) => el.tag === AssistantMessage || el.tag == FunctionCall,
});
let functionCallElement: Element<any> | null = null;

let currentString = '';
for (const element of renderResult) {
if (typeof element === 'string') {
// Model has generated a string response. Record it.
currentString += element;
} else if (isElement(element) && element.tag === FunctionCall) {
if (currentString.trim() !== '') {
conversation.push(<AssistantMessage>{currentString}</AssistantMessage>);
currentString = '';
}
if (isElement(element)) {
conversation.push(memo(element));

// Model has generated a function call.
if (functionCallElement) {
throw new AIJSXError(
`ChatCompletion returned 2 function calls at the same time ${renderResult.join(', ')}`,
ErrorCode.ModelOutputCouldNotBeParsedForTool,
'runtime'
);
if (element.tag === FunctionCall) {
// Model has generated a function call.
if (functionCallElement) {
throw new AIJSXError(
`ChatCompletion returned 2 function calls at the same time ${renderResult.join(', ')}`,
ErrorCode.ModelOutputCouldNotBeParsedForTool,
'runtime'
);
}
functionCallElement = element;
}
conversation.push(memo(element));
functionCallElement = element;
} else {
throw new AIJSXError(
`Unexpected result from render ${renderResult.join(', ')}`,
ErrorCode.ModelOutputCouldNotBeParsedForTool,
'runtime'
logger.debug(
{ text: element },
'<ChatCompletion> emitted something other than <AssistantMessage> or <FunctionCall>, which is unexpected.'
);
}
}
Expand Down
12 changes: 10 additions & 2 deletions packages/ai-jsx/src/core/completion.tsx
Expand Up @@ -299,8 +299,16 @@ export function ConversationHistory({ messages }: { messages: ChatCompletionResp
* ==> "That would be 83,076."
* ```
*/
export function FunctionCall({ name, args }: { name: string; args: Record<string, string | number | boolean | null> }) {
return `Call function ${name} with ${JSON.stringify(args)}`;
export function FunctionCall({
name,
partial,
args,
}: {
name: string;
partial?: boolean;
args: Record<string, string | number | boolean | null>;
}) {
return `Call function ${name} with ${partial ? '(incomplete) ' : ''}${JSON.stringify(args)}`;
}

/**
Expand Down
44 changes: 28 additions & 16 deletions packages/ai-jsx/src/lib/anthropic.tsx
Expand Up @@ -93,7 +93,7 @@ interface AnthropicChatModelProps extends ModelPropsWithChildren {
}
export async function* AnthropicChatModel(
props: AnthropicChatModelProps,
{ render, getContext, logger }: AI.ComponentContext
{ render, getContext, logger, memo }: AI.ComponentContext
): AI.RenderableStream {
if ('functionDefinitions' in props) {
throw new AIJSXError(
Expand Down Expand Up @@ -186,22 +186,34 @@ export async function* AnthropicChatModel(
}
throw err;
}
let resultSoFar = '';
let isFirstResponse = true;
for await (const completion of response) {
let text = completion.completion;
if (isFirstResponse && text.length > 0) {
isFirstResponse = false;
if (text.startsWith(' ')) {
text = text.slice(1);
}
}
resultSoFar += text;
logger.trace({ completion }, 'Got Anthropic stream event');
yield text;
}

logger.debug({ completion: resultSoFar }, 'Anthropic completion finished');
// Embed the stream "within" an <AssistantMessage>, memoizing it to ensure it's only consumed once.
yield (
<AssistantMessage>
{memo(
(async function* (): AI.RenderableStream {
yield AI.AppendOnlyStream;
let resultSoFar = '';
let isFirstResponse = true;
for await (const completion of response) {
let text = completion.completion;
if (isFirstResponse && text.length > 0) {
isFirstResponse = false;
if (text.startsWith(' ')) {
text = text.slice(1);
}
}
resultSoFar += text;
logger.trace({ completion }, 'Got Anthropic stream event');
yield text;
}

logger.debug({ completion: resultSoFar }, 'Anthropic completion finished');
return AI.AppendOnlyStream;
})()
)}
</AssistantMessage>
);

return AI.AppendOnlyStream;
}

3 comments on commit 058c463

@vercel
Copy link

@vercel vercel bot commented on 058c463 Jul 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

ai-jsx-docs – ./packages/docs

ai-jsx-docs-fixie-ai.vercel.app
docs.ai-jsx.com
ai-jsx-docs-git-main-fixie-ai.vercel.app
ai-jsx-docs.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 058c463 Jul 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

ai-jsx-nextjs-demo – ./packages/nextjs-demo

ai-jsx-nextjs-demo.vercel.app
ai-jsx-nextjs-demo-git-main-fixie-ai.vercel.app
ai-jsx-nextjs-demo-fixie-ai.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 058c463 Jul 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

ai-jsx-tutorial-nextjs – ./packages/tutorial-nextjs

ai-jsx-tutorial-nextjs.vercel.app
ai-jsx-tutorial-nextjs-git-main-fixie-ai.vercel.app
ai-jsx-tutorial-nextjs-fixie-ai.vercel.app

Please sign in to comment.