Skip to content

Commit 0130973

Browse files
committed
feat(chat_gpt): compress chats once tasks are completed
ensures we have the tokens to continue making requests
1 parent 1783a73 commit 0130973

2 files changed

Lines changed: 95 additions & 20 deletions

File tree

shard.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ shards:
191191

192192
openai:
193193
git: https://github.com/spider-gazelle/crystal-openai.git
194-
version: 0.9.0+git.commit.e6bfaba7758f992d7cb81cad0109180d5be2d958
194+
version: 0.9.0+git.commit.b6c669a09a57aabcd2d156e0dcc85f0a6255408d
195195

196196
openapi-generator:
197197
git: https://github.com/place-labs/openapi-generator.git

src/placeos-rest-api/controllers/chat_gpt/chat_manager.cr

Lines changed: 94 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ module PlaceOS::Api
6666
ws_sockets[ws_id] = {ws, id, c, req, e}
6767
{ws, id, c, req, e}
6868
end
69-
resp = openai_interaction(client, completion_req, executor, message, chat_id)
70-
ws.send(resp.to_json)
69+
openai_interaction(client, completion_req, executor, message, chat_id) do |resp|
70+
ws.send(resp.to_json)
71+
end
7172
end
7273
rescue error
7374
Log.warn(exception: error) { "failure processing chat message" }
@@ -77,7 +78,7 @@ module PlaceOS::Api
7778

7879
private def setup(chat, chat_payload)
7980
client = build_client
80-
executor = build_executor(chat, chat_payload)
81+
executor = build_executor(chat)
8182
chat_completion = build_completion(build_prompt(chat, chat_payload), executor.functions)
8283

8384
{client, executor, chat_completion}
@@ -103,25 +104,87 @@ module PlaceOS::Api
103104
)
104105
end
105106

106-
private def openai_interaction(client, request, executor, message, chat_id) : NamedTuple(chat_id: String, message: String?)
107+
@total_tokens : Int32 = 0
108+
109+
private def openai_interaction(client, request, executor, message, chat_id, &) : Nil
107110
request.messages << OpenAI::ChatMessage.new(role: :user, content: message)
108111
save_history(chat_id, :user, message)
112+
113+
# track token usage
114+
discardable_tokens = 0
115+
tracking_total = 0
116+
calculate_discard = false
117+
109118
loop do
119+
# ensure new request will fit here
120+
# cleanup old messages, saving first system prompt and then removing messages beyond that until we're within the limit
121+
# we could also restore messages once a task has been completed if there is space
122+
# TODO::
123+
124+
# track token usage
110125
resp = client.chat_completion(request)
126+
@total_tokens = resp.usage.total_tokens
127+
128+
if calculate_discard
129+
discardable_tokens += resp.usage.prompt_tokens - tracking_total
130+
calculate_discard = false
131+
end
132+
tracking_total = @total_tokens
133+
134+
# save relevant history
111135
msg = resp.choices.first.message
112136
request.messages << msg
113-
save_history(chat_id, msg)
137+
save_history(chat_id, msg) unless msg.function_call || (msg.role.function? && msg.name != "task_complete")
114138

139+
# perform function calls until we get a response for the user
115140
if func_call = msg.function_call
116-
func_res = executor.execute(func_call)
141+
discardable_tokens += resp.usage.completion_tokens
142+
143+
# handle the AI not providing a valid function name, we want it to retry
144+
func_res = begin
145+
executor.execute(func_call)
146+
rescue ex
147+
Log.error(exception: ex) { "executing function call" }
148+
reply = "Encountered error: #{ex.message}"
149+
result = DriverResponse.new(reply).as(JSON::Serializable)
150+
request.messages << OpenAI::ChatMessage.new(:function, result.to_pretty_json, func_call.name)
151+
next
152+
end
153+
154+
# process the function result
155+
case func_res.name
156+
when "task_complete"
157+
cleanup_messages(request, discardable_tokens)
158+
discardable_tokens = 0
159+
summary = TaskCompleted.from_json func_call.arguments.as_s
160+
yield({chat_id: chat_id, message: "condensing progress: #{summary.details}", type: :progress, function: func_res.name, usage: resp.usage, compressed_usage: @total_tokens})
161+
when "list_function_schemas"
162+
calculate_discard = true
163+
discover = FunctionDiscovery.from_json func_call.arguments.as_s
164+
yield({chat_id: chat_id, message: "checking #{discover.id} capabilities", type: :progress, function: func_res.name, usage: resp.usage})
165+
when "call_function"
166+
calculate_discard = true
167+
execute = FunctionExecutor.from_json func_call.arguments.as_s
168+
yield({chat_id: chat_id, message: "performing action: #{execute.id}.#{execute.function}(#{execute.parameters})", type: :progress, function: func_res.name, usage: resp.usage})
169+
end
117170
request.messages << func_res
118-
save_history(chat_id, msg)
119171
next
120172
end
121-
break {chat_id: chat_id, message: msg.content}
173+
174+
cleanup_messages(request, discardable_tokens)
175+
yield({chat_id: chat_id, message: msg.content, type: :response, usage: resp.usage, compressed_usage: @total_tokens})
176+
break
122177
end
123178
end
124179

180+
private def cleanup_messages(request, discardable_tokens)
181+
# keep task summaries
182+
request.messages.reject! { |mess| mess.function_call || (mess.role.function? && mess.name != "task_complete") }
183+
184+
# a good estimate of the total tokens once the cleanup is complete
185+
@total_tokens = @total_tokens - discardable_tokens
186+
end
187+
125188
private def save_history(chat_id : String, role : PlaceOS::Model::ChatMessage::Role, message : String, func_name : String? = nil, func_args : JSON::Any? = nil) : Nil
126189
PlaceOS::Model::ChatMessage.create!(role: role, chat_id: chat_id, content: message, function_name: func_name, function_args: func_args)
127190
end
@@ -148,7 +211,8 @@ module PlaceOS::Api
148211
str << "my phone number is: #{user.phone}\n" if user.phone.presence
149212
str << "my swipe card number is: #{user.card_number}\n" if user.card_number.presence
150213
str << "my user_id is: #{user.id}\n"
151-
str << "use these details in function calls as required\n"
214+
str << "use these details in function calls as required.\n"
215+
str << "perform one task at a time, making as many function calls as required to complete a task. Once a task is complete call the task_complete function with details of the progress you've made.\n"
152216
str << "the chat client prepends the date-time each message was sent at in the following format YYYY-MM-DD HH:mm:ss +ZZ:ZZ:ZZ"
153217
}
154218
)
@@ -177,19 +241,12 @@ module PlaceOS::Api
177241
Payload.from_json grab_driver_status(chat, LLM_DRIVER, LLM_DRIVER_PROMPT)
178242
end
179243

180-
private def build_executor(chat, payload : Payload?)
244+
private def build_executor(chat)
181245
executor = OpenAI::FunctionExecutor.new
182246

183-
description = if payload
184-
"You have the following capability list, described in the following JSON:\n```json\n#{payload.capabilities.to_json}\n```\n" +
185-
"if a request could benefit from these capabilities, obtain the list of functions by providing the id string."
186-
else
187-
"if a request could benefit from a capability, obtain the list of functions by providing the id string"
188-
end
189-
190247
executor.add(
191248
name: "list_function_schemas",
192-
description: description,
249+
description: "if a request could benefit from a capability, obtain the list of function schemas by providing the id string",
193250
clz: FunctionDiscovery
194251
) do |call|
195252
request = call.as(FunctionDiscovery)
@@ -206,7 +263,8 @@ module PlaceOS::Api
206263
executor.add(
207264
name: "call_function",
208265
description: "Executes functionality offered by a capability, you'll need to obtain the function schema to perform requests",
209-
clz: FunctionExecutor) do |call|
266+
clz: FunctionExecutor
267+
) do |call|
210268
request = call.as(FunctionExecutor)
211269
reply = "No response received"
212270
begin
@@ -219,6 +277,15 @@ module PlaceOS::Api
219277
DriverResponse.new(reply).as(JSON::Serializable)
220278
end
221279

280+
executor.add(
281+
name: "task_complete",
282+
description: "Once a task is complete, call this function with the details that are relevant to the conversion. Provide enough detail so you don't perform the actions again and can formulate a response to the user",
283+
clz: TaskCompleted
284+
) do |call|
285+
request = call.as(TaskCompleted)
286+
request.as(JSON::Serializable)
287+
end
288+
222289
executor
223290
end
224291

@@ -279,6 +346,14 @@ module PlaceOS::Api
279346
getter id : String
280347
end
281348

349+
private struct TaskCompleted
350+
extend OpenAI::FuncMarker
351+
include JSON::Serializable
352+
353+
@[JSON::Field(description: "the details of the task that are relevant to continuing the conversion")]
354+
getter details : String
355+
end
356+
282357
private record DriverResponse, body : String do
283358
include JSON::Serializable
284359
end

0 commit comments

Comments
 (0)