-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagent.lua
More file actions
1825 lines (1718 loc) · 63.7 KB
/
Copy pathagent.lua
File metadata and controls
1825 lines (1718 loc) · 63.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
local core = require "core"
local common = require "core.common"
local config = require "core.config"
local json = require "core.json"
local jsonutil = require "plugins.assistant.jsonutil"
local history_normalizer = require "plugins.assistant.history_normalizer"
local Tool = require "plugins.assistant.tool"
local tool_context = require "plugins.assistant.tool_context"
local tool_router = require "plugins.assistant.tool_router"
local Object = require "core.object"
local unpack = table.unpack or unpack
---Provider-level behavior for assistant conversations.
---
---Agents describe provider capabilities, request payloads, response parsing,
---tool schemas, and provider-history shaping. Communication is intentionally
---handled by backend modules.
---@class assistant.Agent : core.object
---@field name string Stable provider id.
---@field display_name string Human-readable provider name.
---@field version string Provider adapter version.
---@field backend string Backend id used to communicate with this provider.
---@field base_url string HTTP base URL for HTTP-compatible providers.
---@field endpoint string Chat or responses endpoint.
---@field models_endpoint string Model-list endpoint.
---@field api_format string Provider API format, usually `"chat"` or `"responses"`.
---@field stream_format string Streaming format used by the backend.
---@field model string Active provider model id.
---@field api_key_env string|nil Environment variable used for an API key.
---@field api_key string|nil Direct API key value.
---@field stream boolean Whether streaming responses are enabled.
---@field capabilities table<string, boolean>
---@field collaboration_modes table[]|nil
---@field compact_implementation_tools boolean
---@field tools table<string, assistant.Tool.registration>
---@field options table<string, any>
---@field model_metadata table<string, any>
---@field super core.object
local Agent = Object:extend()
local REASONING_EFFORT_VALUES = {
none = true,
low = true,
medium = true,
high = true
}
local function sanitize_provider_payload(value, seen)
if type(value) == "string" then
return tool_context.sanitize_text(value)
end
if type(value) ~= "table" then return value end
seen = seen or {}
if seen[value] then return value end
seen[value] = true
for key, item in pairs(value) do
value[key] = sanitize_provider_payload(item, seen)
end
return value
end
---Create a new instance.
---@param options table|nil Agent configuration and provider defaults.
function Agent:new(options)
options = options or {}
local explicit_context = options.options
and options.options.context ~= nil
self.name = options.name or "generic"
self.display_name = options.display_name or self.name
self.version = options.version or "0.1"
self.backend = options.backend or "http"
self.base_url = options.base_url or "http://localhost:11434"
self.endpoint = options.endpoint or "/v1/chat/completions"
self.models_endpoint = options.models_endpoint or "/v1/models"
self.api_format = options.api_format or "chat"
self.stream_format = options.stream_format or "sse"
self.model = options.model or "default"
self.api_key_env = options.api_key_env
self.api_key = options.api_key
self.stream = options.stream ~= false
self.reasoning_effort = options.reasoning_effort
self.reasoning_effort_inherited = options.reasoning_effort_inherited == true
self.capabilities = common.merge({
reports_usage = false,
reports_context = false,
compact = false,
delete_conversation = false,
list_conversations = false,
rename_conversation = false,
collaboration_modes = false,
user_input_requests = false,
approval_requests = false,
stream_responses = false,
tool_calling = false,
keep_alive = false,
local_compact = false,
vision = false,
keep_reasoning_content = false,
require_assistant_reasoning_content = false
}, options.capabilities or {})
self.collaboration_modes = options.collaboration_modes
self.compact_implementation_tools = options.compact_implementation_tools == true
self.tools = {}
self._loading = false
self.options = common.merge({
context = 16384,
temperature = 0.2,
top_k_sampling = 40,
repeat_penalty = 1.1,
min_p_sampling = 0.05,
top_p_sampling = 0.95
}, options.options or {})
self.model_metadata = common.merge({
context_window = self.options.context,
stream_tool_calls = self.capabilities.stream_responses == true
and self.capabilities.tool_calling == true,
parallel_tool_calls = false,
reports_usage = self.capabilities.reports_usage == true,
preferred_timeout_ms = nil,
default_max_tokens = nil,
max_output_tokens = nil,
chat_reasoning_effort = false
}, options.model_metadata or {})
if not explicit_context and self.model_metadata.context_window then
self.options.context = self.model_metadata.context_window
end
end
---Return whether capability is available.
---@param name string
---@return boolean
function Agent:has_capability(name)
return self.capabilities and self.capabilities[name] == true
end
---Hook for provider-specific config fields.
---@param conf table
function Agent:configure_provider(_) end
---Handle configure.
---@param conf table|nil User/plugin configuration.
---@return assistant.Agent self
function Agent:configure(conf)
conf = conf or {}
local supports_api_key = self.api_key_env and self.api_key_env ~= ""
if conf.model and conf.model ~= "" then self.model = conf.model end
if conf.base_url and conf.base_url ~= "" then self.base_url = conf.base_url end
if supports_api_key and conf.api_key and conf.api_key ~= "" then
self.api_key = conf.api_key
end
if supports_api_key and conf.api_key_env and conf.api_key_env ~= "" then
self.api_key_env = conf.api_key_env
end
if self:has_capability("keep_alive") and conf.keep_alive and conf.keep_alive ~= "" then
self.keep_alive = conf.keep_alive
end
if type(conf.capabilities) == "table" then
self.capabilities = common.merge(self.capabilities or {}, conf.capabilities)
end
if type(conf.tool_calling) == "boolean" then
self.capabilities.tool_calling = conf.tool_calling
end
self.model_metadata.stream_tool_calls = self.capabilities.stream_responses == true
and self.capabilities.tool_calling == true
self.model_metadata.reports_usage = self.capabilities.reports_usage == true
if conf.reasoning_effort ~= nil then
self.reasoning_effort = conf.reasoning_effort
end
self.reasoning_effort_inherited = conf.reasoning_effort_inherited == true
self.stream = conf.stream ~= false and self:has_capability("stream_responses")
self:configure_provider(conf)
return self
end
---Return the collaboration modes.
---@return table[] modes
function Agent:get_collaboration_modes()
return self.collaboration_modes or {
{ id = "implementation", label = "Implementation" },
{ id = "plan", label = "Plan" }
}
end
---Build collaboration mode.
---@param mode string|table|nil
---@return table|nil
function Agent:build_collaboration_mode(mode)
return mode
end
---Normalize collaboration mode.
---@param mode string|table|nil
---@return string
function Agent:normalize_collaboration_mode(mode)
if type(mode) == "table" then
mode = mode.id or mode.mode or mode.name
end
mode = tostring(mode or "")
if mode == "default" then return "implementation" end
if mode == "" then return nil end
return mode
end
---Handle clone table.
local function clone_table(value)
if type(value) ~= "table" then return value end
local copy = {}
for key, item in pairs(value) do
copy[key] = clone_table(item)
end
return copy
end
---Return whether hash text is available.
local function hash_text(text)
text = tostring(text or "")
local hash = 2166136261
for i = 1, #text do
hash = (hash * 16777619 + text:byte(i)) % 4294967296
end
return string.format("%08x", hash)
end
---Handle tool call has omitted arguments.
local function tool_call_has_omitted_arguments(call)
local fn = type(call) == "table" and type(call["function"]) == "table" and call["function"] or nil
if not fn or type(fn.arguments) ~= "string" then return false end
local ok, decoded = pcall(json.decode, fn.arguments)
if not ok or type(decoded) ~= "table" then return false end
return history_normalizer.contains_omitted_tool_argument(decoded)
end
---Handle omit omitted tool calls.
local function omit_omitted_tool_calls(message)
if type(message) ~= "table" or message.role ~= "assistant" or type(message.tool_calls) ~= "table" then
return message
end
local omitted_ids = {}
for _, call in ipairs(message.tool_calls) do
if tool_call_has_omitted_arguments(call) then
local id = tostring(call.id or "")
if id ~= "" then omitted_ids[id] = true end
end
end
if not next(omitted_ids) then return message end
return {
omitted_tool_call_ids = omitted_ids,
omit_provider_message = true
}
end
---Return provider tool call id.
---@param call table
---@return string
local function provider_tool_call_id(call)
return tostring(type(call) == "table" and (call.id or call.call_id) or "")
end
---Return provider tool call name.
---@param call table
---@return string|nil
local function provider_tool_call_name(call)
if type(call) ~= "table" then return nil end
local fn = type(call["function"]) == "table" and call["function"] or nil
return fn and fn.name or call.name
end
---Remove completed tool calls from an assistant provider message.
---@param message table
---@param completed_ids table<string, boolean>
---@return table|nil
local function remove_completed_tool_calls(message, completed_ids)
if type(message) ~= "table" or type(message.tool_calls) ~= "table" then return message end
local remaining = {}
for _, call in ipairs(message.tool_calls) do
local id = provider_tool_call_id(call)
if id == "" or not completed_ids[id] then
table.insert(remaining, clone_table(call))
end
end
local content = message.content
if #remaining == 0 and (content == nil or content == "") then return nil end
local copy = clone_table(message)
if #remaining > 0 then
copy.tool_calls = remaining
else
copy.tool_calls = nil
end
return copy
end
---Handle trailing tool exchange indexes.
local function trailing_tool_exchange_indexes(messages)
local preserve = {}
if type(messages) ~= "table" or #messages == 0 then return preserve end
local last = messages[#messages]
if type(last) == "table" and type(last.tool_calls) == "table" then
preserve[#messages] = true
return preserve
end
local index = #messages
while index >= 1 do
local message = messages[index]
if type(message) == "table" and message.role == "tool" then
preserve[index] = true
index = index - 1
else
break
end
end
local tool_call_message = messages[index]
if type(tool_call_message) == "table" and type(tool_call_message.tool_calls) == "table" then
preserve[index] = true
return preserve
end
return {}
end
---Handle unresolved tool call indexes.
local function unresolved_tool_call_indexes(messages)
local tool_results = {}
if type(messages) ~= "table" then return {} end
for _, message in ipairs(messages) do
if type(message) == "table" and message.role == "tool" then
local id = tostring(message.tool_call_id or "")
if id ~= "" then tool_results[id] = true end
end
end
local preserve = {}
for index, message in ipairs(messages) do
if type(message) == "table" and type(message.tool_calls) == "table" then
for _, call in ipairs(message.tool_calls) do
local id = tostring(type(call) == "table" and call.id or "")
if id ~= "" and not tool_results[id] then
preserve[index] = true
break
end
end
end
end
return preserve
end
---Handle unprocessed tool result indexes.
local function unprocessed_tool_result_indexes(messages)
local preserve = {}
if type(messages) ~= "table" then return preserve end
local tool_call_indexes = {}
for index, message in ipairs(messages) do
if type(message) == "table" and type(message.tool_calls) == "table" then
for _, call in ipairs(message.tool_calls) do
local id = tostring(type(call) == "table" and call.id or "")
if id ~= "" then tool_call_indexes[id] = index end
end
end
end
local has_later_assistant = false
for index = #messages, 1, -1 do
local message = messages[index]
if type(message) == "table" and message.role == "assistant" then
has_later_assistant = true
elseif type(message) == "table" and message.role == "tool" and not has_later_assistant then
preserve[index] = true
local call_index = tool_call_indexes[tostring(message.tool_call_id or "")]
if call_index then preserve[call_index] = true end
end
end
return preserve
end
---Resolve a registered tool for a provider call.
---@param call table
---@return assistant.Tool.registration|nil tool
function Agent:tool_for_provider_call(call)
local name = self:resolve_tool_name(provider_tool_call_name(call))
local tool = self.tools[name or ""]
if tool then return tool end
if name == "apply_patch" then
local ok, applypatch = pcall(require, "plugins.assistant.tool.applypatch")
if ok and type(applypatch) == "table" then
return {
name = "apply_patch",
compact_provider_call = function(provider_call, compact_context)
return Tool.compact_provider_call({ name = "apply_patch" }, provider_call, compact_context)
end,
result_is_successful = applypatch.result_is_successful,
compact_history = applypatch.compact_history
}
end
end
return nil
end
---Compact one provider tool call through its registered tool.
---@param call table
---@param compact_context table
---@return table call
function Agent:compact_provider_call(call, compact_context)
local tool = self:tool_for_provider_call(call)
if tool and tool.compact_provider_call then
local ok, compacted = pcall(tool.compact_provider_call, call, compact_context)
if ok and type(compacted) == "table" then return compacted end
end
return Tool.compact_provider_call(tool or {}, call, compact_context)
end
---Compact one provider message through registered tools.
---@param message table
---@param compact_context table
---@return table message
function Agent:compact_provider_message(message, compact_context)
if type(message) ~= "table" then return message end
local copy = Tool.clone_table(message)
if type(copy.tool_calls) == "table" then
for index, call in ipairs(copy.tool_calls) do
copy.tool_calls[index] = self:compact_provider_call(call, compact_context)
end
end
if copy.type == "function_call" then
copy = self:compact_provider_call(copy, compact_context)
end
if copy.role == "tool" and type(copy.content) == "string" then
copy.content = Tool.compact_long_text(copy.content)
end
if copy.type == "function_call_output" and type(copy.output) == "string" then
copy.output = Tool.compact_long_text(copy.output)
end
return copy
end
---Return successful historical tool result maps.
---@param messages table[]
---@param compact_context table
---@return table<string, boolean> ids
---@return table<string, string> texts
local function successful_tool_result_maps(agent, messages, compact_context)
local calls_by_id = {}
for _, message in ipairs(messages or {}) do
if type(message) == "table" and type(message.tool_calls) == "table" then
for _, call in ipairs(message.tool_calls) do
local id = provider_tool_call_id(call)
if id ~= "" then calls_by_id[id] = call end
end
elseif type(message) == "table" and message.type == "function_call" then
local id = provider_tool_call_id(message)
if id ~= "" then calls_by_id[id] = message end
end
end
local ids = {}
local texts = {}
for _, message in ipairs(messages or {}) do
if type(message) == "table" and (message.role == "tool" or message.type == "function_call_output") then
local id = tostring(message.tool_call_id or message.call_id or "")
local call = calls_by_id[id]
local tool = call and agent:tool_for_provider_call(call)
if id ~= "" and tool and tool.result_is_successful then
local ok, successful = pcall(tool.result_is_successful, call, message, compact_context)
if ok and successful then
ids[id] = true
texts[id] = tostring(message.content or message.output or "")
end
end
end
end
return ids, texts
end
---Return provider messages after optional historical tool compaction.
---@param messages table[] Provider message list built from a conversation.
---@param conversation assistant.Conversation|nil Source conversation.
---@return table[] messages
function Agent:compact_provider_messages(messages, conversation)
local conf = config.plugins and config.plugins.assistant or {}
if conf.compact_tool_history ~= true then return messages end
if not self.compact_implementation_tools then return messages end
local compact_context = {
project_dir = conversation and conversation.project_dir,
conversation = conversation,
agent = self
}
local compacted = {}
local skipped_tool_result_ids = {}
local tool_result_ids, tool_result_texts = successful_tool_result_maps(self, messages, compact_context)
local preserve_indexes = trailing_tool_exchange_indexes(messages)
for index in pairs(unresolved_tool_call_indexes(messages)) do
preserve_indexes[index] = true
end
for index in pairs(unprocessed_tool_result_indexes(messages)) do
preserve_indexes[index] = true
end
local function compact_history_for(message, included_ids)
local inserted_ids = {}
local inserted = false
local tools_seen = {}
for _, call in ipairs(type(message) == "table" and message.tool_calls or {}) do
local id = provider_tool_call_id(call)
if id ~= "" and included_ids[id] then
local tool = self:tool_for_provider_call(call)
if tool and tool.compact_history and not tools_seen[tool] then
tools_seen[tool] = true
local ok, snapshots = pcall(tool.compact_history, message, compact_context, included_ids, tool_result_texts)
if ok and type(snapshots) == "table" then
for _, snapshot in ipairs(snapshots) do
if type(snapshot) == "table" then
table.insert(compacted, snapshot)
inserted = true
end
end
end
end
end
end
if inserted then
for id in pairs(included_ids) do inserted_ids[id] = true end
end
return inserted_ids, inserted
end
for index, message in ipairs(messages or {}) do
local compacted_message
local completed_ids = {}
local completed_count = 0
if type(message) == "table" and type(message.tool_calls) == "table" then
for _, call in ipairs(message.tool_calls) do
local id = provider_tool_call_id(call)
if id ~= "" and tool_result_ids[id] then
completed_ids[id] = true
completed_count = completed_count + 1
end
end
end
if preserve_indexes[index] then
compacted_message = clone_table(message)
elseif completed_count > 0 then
local inserted_ids, inserted = compact_history_for(message, completed_ids)
for id in pairs(inserted_ids) do
skipped_tool_result_ids[id] = true
end
if inserted then
compacted_message = remove_completed_tool_calls(message, inserted_ids)
else
compacted_message = omit_omitted_tool_calls(self:compact_provider_message(message, compact_context))
end
else
compacted_message = omit_omitted_tool_calls(self:compact_provider_message(message, compact_context))
end
if type(compacted_message) == "table" and compacted_message.omitted_tool_call_ids then
for id in pairs(compacted_message.omitted_tool_call_ids) do
skipped_tool_result_ids[id] = true
end
if compacted_message.omit_provider_message then
local all_ids = {}
if type(message) == "table" and type(message.tool_calls) == "table" then
for _, call in ipairs(message.tool_calls) do
local id = provider_tool_call_id(call)
if id ~= "" then all_ids[id] = true end
end
end
compact_history_for(message, all_ids)
end
if not compacted_message.omit_provider_message then
compacted_message.omitted_tool_call_ids = nil
table.insert(compacted, compacted_message)
end
elseif compacted_message ~= nil then
local skip_tool_result = type(compacted_message) == "table"
and compacted_message.role == "tool"
and skipped_tool_result_ids[tostring(compacted_message.tool_call_id or "")]
if not skip_tool_result then
table.insert(compacted, compacted_message)
end
end
end
return compacted
end
---Return the mode instructions.
---@param conversation assistant.Conversation|nil
---@return string|nil
function Agent:get_mode_instructions(conversation)
local mode = self:normalize_collaboration_mode(conversation and conversation.collaboration_mode)
if mode == "plan" then
return table.concat({
"Collaboration mode: Plan.",
"You are in Plan mode until the host switches the collaboration mode.",
"User requests such as `implement`, `continue`, or `go ahead` do not by themselves permit mutation while Plan mode is active.",
"In this mode, do not create, edit, delete, patch, format, build, install, or otherwise mutate project files.",
"Durable assistant memory updates may be made only when appropriate and after approval.",
"You may use read-only terminal inspection commands when the plan depends on local environment details.",
"Do not ask the user before using clearly read-only inspection commands such as `ls`, `pwd`, `git status`, `find`, `stat`, or `pkg-config --exists`.",
"Do not imply that you are implementing now; phrase the response as a plan for later implementation.",
"Use read-only inspection tools to ground your plan before asking questions.",
"Explore first, ask second: answer discoverable project questions through targeted non-mutating inspection before asking the user.",
"Use request_user_input only for choices that materially change the plan and cannot be answered from the project.",
"Do not write implementation code, full source files, patches, or long code blocks in Plan mode; describe intended files, interfaces, behavior, tests, and risks in prose or concise pseudocode only.",
"When the plan is decision-complete, respond with one Markdown plan that another engineer or agent can implement directly.",
"End the final plan response with exactly `Plan Drafted!` on its own line.",
"After presenting a decision-complete plan, you must call implement_plan if it is available to ask whether to switch to Implementation mode and start the work.",
"Do not finish a decision-complete Plan-mode response with only prose; include the final plan and call implement_plan in the same turn.",
"Do not end a Plan-mode response after a decision-complete plan without calling implement_plan.",
"If you determine that implementation requires creating, editing, deleting, patching, formatting, building, installing, or otherwise mutating project state, present the plan and call implement_plan instead of attempting the mutation in Plan mode.",
"Choose reasonable defaults instead of asking for confirmation when the user's request is clear.",
"Do not include private reasoning markup in user-visible responses.",
"Do not ask whether to proceed in prose."
}, "\n")
end
if mode == "implementation" then
local model = tostring(self.model or ""):lower()
local uses_gpt_tools = model:find("gpt", 1, true) ~= nil
local edit_instructions = uses_gpt_tools and {
"Use apply_patch for file creation, edits, and deletions.",
"When updating, moving, or deleting an existing file with apply_patch, use recent exact file context. If the available context is stale, summarized, omitted, or compacted, read the target file or exact region first.",
"If apply_patch fails with a context or removal mismatch, do not retry the same patch blindly; read the current target file or exact region, then rebuild the patch from that fresh content."
} or {
"Use edit for precise changes to existing files. Each edits[].oldText must match a unique, non-overlapping region of the original file.",
"Use write only for new files or complete rewrites.",
"Use read to inspect exact current file content before editing when context may be stale, summarized, omitted, or compacted."
}
local lines = {
"Collaboration mode: Implementation.",
"Carry out the user's requested implementation using the available tools.",
"When the user asks to replace or update a specific string, repository slug, URL, symbol, or path, keep the scope to that exact value and obvious direct variants unless the user explicitly asks to broaden it.",
"For exact replacement tasks, first search for the complete old value exactly as given. Do not search for or edit broader substrings, adjacent product names, workflow names, comments, or dependency/tooling URLs unless they also contain the complete old value or the user explicitly requests that broader rename.",
"For local project editing tasks, prefer local inspection tools. Use web tools only when the user asks for current external information or local project context is insufficient.",
}
for _, line in ipairs(edit_instructions) do table.insert(lines, line) end
table.insert(lines, "Use exec_command for shell commands, exec_status to poll, write_stdin to send input, send_eof to close stdin, interrupt_exec to interrupt, and close_exec to terminate an ongoing command session.")
table.insert(lines, "If the transcript contains a proposed plan and the user asks to implement it, treat that plan as the implementation specification unless the user changes direction.")
table.insert(lines, "Do not include private reasoning markup in user-visible responses.")
return table.concat(lines, "\n")
end
end
---Handle provider messages for conversation.
---@param conversation assistant.Conversation
---@return table[] messages
function Agent:provider_messages_for_conversation(conversation)
if conversation and conversation.refresh_context then
conversation:refresh_context(self)
end
if conversation and conversation.refresh_environment_context then
conversation:refresh_environment_context(self)
end
local messages = history_normalizer.normalize_chat_messages(
self:compact_provider_messages(conversation:to_provider_messages(), conversation)
)
if not self:should_persist_reasoning_content() then
for _, message in ipairs(messages) do
if type(message) == "table" then
message.reasoning_content = nil
end
end
elseif self:should_require_assistant_reasoning_content() then
for _, message in ipairs(messages) do
if type(message) == "table"
and message.role == "assistant"
and message.reasoning_content == nil
then
message.reasoning_content = ""
end
end
end
sanitize_provider_payload(messages)
local instructions = self:get_mode_instructions(conversation)
if not instructions or instructions == "" then return messages end
local result = {}
local inserted = false
for _, message in ipairs(messages) do
if message.role == "system" and not inserted then
local copy = common.merge({}, message)
copy.content = table.concat({ copy.content or "", instructions }, "\n\n")
table.insert(result, copy)
inserted = true
else
table.insert(result, message)
end
end
if not inserted then
table.insert(result, 1, { role = "system", content = instructions })
end
sanitize_provider_payload(result)
return result
end
---Return display/provider text from a tool result.
---@param result any
---@return string
function Agent:tool_result_text(result)
local text
if type(result) == "table" then
text = tostring(result.text or result.message or "")
else
text = tostring(result or "")
end
return tool_context.sanitize_text(text)
end
---Handle tool names for mode.
---@param conversation assistant.Conversation|nil
---@return string[]|nil names
function Agent:tool_names_for_mode(conversation)
return tool_router.tool_names_for_mode(self, conversation)
end
---Normalize user input request.
---@param request table
---@return table
function Agent:normalize_user_input_request(request)
return request
end
---Format user input response.
---@param answers table|nil
---@return table
function Agent:format_user_input_response(_, _, answers)
return answers or {}
end
---Normalize approval request.
---@param request table
---@return table
function Agent:normalize_approval_request(request)
return request
end
---Format approval response.
---@param decision string
---@return table|string
function Agent:format_approval_response(_, decision)
return decision or {}
end
---Handle register tool.
---@param name string
---@param options assistant.Tool.registration
function Agent:register_tool(name, options)
self.tools[name] = options
end
---Handle unregister tool.
---@param name string
function Agent:unregister_tool(name)
self.tools[name] = nil
end
---Set the loading.
---@param value boolean
function Agent:set_loading(value)
self._loading = value and true or false
end
---Handle loading.
---@return boolean
function Agent:loading()
return self._loading == true
end
---Handle sorted tool names.
local function sorted_tool_names(tools, selected)
local names = {}
if type(selected) == "table" then
for _, name in ipairs(selected) do
if tools[name] then table.insert(names, name) end
end
else
for name in pairs(tools) do
table.insert(names, name)
end
end
table.sort(names)
return names
end
---Handle tool parameters schema.
---@param tool assistant.Tool.registration
---@return table schema
function Agent:tool_parameters_schema(tool)
local properties = {}
local required = {}
for _, param in ipairs(tool.params or {}) do
properties[param.name] = param.schema or {
type = param.type or "string",
description = param.description or ""
}
if param.enum then properties[param.name].enum = param.enum end
if param.required ~= false then
table.insert(required, param.name)
end
end
local parameters = {
type = "object",
properties = properties
}
if tool.additional_properties ~= nil then
parameters.additionalProperties = tool.additional_properties
end
if #required > 0 then
parameters.required = required
end
return parameters
end
---Handle generate tools info.
---@param selected string[]|nil
---@return table[]|nil tools
function Agent:generate_tools_info(selected)
local result = {}
for _, name in ipairs(sorted_tool_names(self.tools, selected)) do
local tool = self.tools[name]
table.insert(result, {
type = "function",
["function"] = {
name = name,
description = tool.description or "",
parameters = self:tool_parameters_schema(tool)
}
})
end
return #result > 0 and result or nil
end
---Return whether tools is available.
---@return boolean
function Agent:has_tools()
return next(self.tools) ~= nil
end
---Return the api key.
---@return string|nil
function Agent:get_api_key()
if self.api_key and self.api_key ~= "" then return self.api_key end
if self.api_key_env and self.api_key_env ~= "" then
return os.getenv(self.api_key_env)
end
end
---Return the headers.
---@return table<string, string>
function Agent:get_headers()
local headers = {
["Content-Type"] = "application/json"
}
local key = self:get_api_key()
if key and key ~= "" then
headers.Authorization = "Bearer " .. key
end
return headers
end
---Return a snapshot of the current runtime environment.
---@param project_dir string|nil
---@return table snapshot
function Agent:environment_context_snapshot(project_dir)
project_dir = project_dir or (core.root_project() and core.root_project().path) or "."
local platform = rawget(_G, "PLATFORM") or "unknown"
local arch = rawget(_G, "ARCH") or "unknown"
local pathsep = rawget(_G, "PATHSEP") or package.config:sub(1, 1)
local shell = os.getenv("SHELL") or os.getenv("COMSPEC") or "/bin/sh"
local project_roots = {}
for _, project in ipairs(core.projects or {}) do
if project.path and project.path ~= "" then
table.insert(project_roots, tostring(project.path))
end
end
local snapshot = {
agent = self.name,
project_dir = tostring(project_dir),
cwd = tostring(project_dir),
shell = tostring(shell),
current_date = os.date("%Y-%m-%d"),
timezone = os.getenv("TZ") or os.date("%Z"),
platform = tostring(platform),
architecture = tostring(arch),
path_separator = tostring(pathsep),
project_roots = project_roots
}
snapshot.hash = hash_text(table.concat({
snapshot.agent or "",
snapshot.project_dir or "",
snapshot.cwd or "",
snapshot.shell or "",
snapshot.current_date or "",
snapshot.timezone or "",
snapshot.platform or "",
snapshot.architecture or "",
snapshot.path_separator or "",
table.concat(snapshot.project_roots or {}, "\n")
}, "\n"))
return snapshot
end
---Render a runtime environment snapshot for model context.
---@param snapshot table|nil
---@return string message
function Agent:render_environment_context(snapshot)
snapshot = snapshot or self:environment_context_snapshot()
local lines = {
"Runtime environment:",
" - cwd: " .. tostring(snapshot.cwd or snapshot.project_dir or "."),
" - shell: " .. tostring(snapshot.shell or ""),
" - current_date: " .. tostring(snapshot.current_date or ""),
" - timezone: " .. tostring(snapshot.timezone or ""),
" - platform: " .. tostring(snapshot.platform or ""),
" - architecture: " .. tostring(snapshot.architecture or ""),
" - path_separator: " .. tostring(snapshot.path_separator or "")
}
local roots = snapshot.project_roots or {}
if #roots > 0 then
table.insert(lines, " - project_roots: " .. table.concat(roots, ", "))
end
return table.concat(lines, "\n")
end
---Return a provider-only runtime environment context message.
---@param project_dir string|nil
---@return table message
function Agent:environment_context_message(project_dir)
local snapshot = self:environment_context_snapshot(project_dir)
return {
role = "user",
content = self:render_environment_context(snapshot),
meta = {
contextual = true,
environment_context = true,
provider_only = true,
environment_snapshot = snapshot
}
}
end
---Return the environment message.
---@return string
function Agent:get_environment_message()
return self:render_environment_context(self:environment_context_snapshot())
end
---Build context fragments.
---@param project_dir string|nil
---@param project_instructions string|nil
---@return table[] fragments
function Agent:build_context_fragments(project_dir, project_instructions)
project_dir = project_dir or (core.root_project() and core.root_project().path) or "."
local fragments = {
{
id = "base",
content = table.concat({
"You are Pragma, a coding assistant working in the project at `" .. project_dir .. "`.",
"Help the user inspect, modify, and verify code in this project.",
"Be precise, practical, and oriented toward changes that fit the existing codebase.",
"Use project context and available tools when needed. Explain important decisions clearly.",
"Project memories are durable notes for this project: use search_memory before updating or deleting a memory when you do not know its id.",
"Use remember only for stable user preferences, project conventions, durable decisions, or recurring facts that should affect future sessions.",
"Do not store secrets, transient command output, one-off task state, or facts already obvious from project files.",
"Use forget when the user says a memory is wrong, obsolete, superseded, or no longer applicable."
}, "\n")
},
{
id = "permissions",
content = table.concat({
"Tool safety:",
"- Read-only inspection tools may run without confirmation when their target is inside the active project.",
"- Commands and tools that create, edit, delete, build, install, access the network, or leave the project require approval.",
"- Plan mode may inspect and ask questions, but must not change project files."
}, "\n")
}
}
if project_instructions and project_instructions ~= "" then
table.insert(fragments, {
id = "project_instructions",
content = "Project AGENTS.md instructions:\n" .. project_instructions
})
end
return fragments
end
---Handle context snapshot.
---@param project_dir string
---@param project_instructions string|nil
---@return table snapshot
function Agent:context_snapshot(project_dir, project_instructions)
local fragments = self:build_context_fragments(project_dir, project_instructions)
local snapshot = {
agent = self.name,
model = self.model,
project_dir = project_dir,
fragments = {}
}
for _, fragment in ipairs(fragments) do
local content = fragment.content or ""
table.insert(snapshot.fragments, {
id = fragment.id,