agentkernel-cli 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentkernel/agent.py +8 -2
- agentkernel/cli.py +236 -17
- agentkernel/config.py +6 -1
- agentkernel/context/manager.py +13 -2
- agentkernel/curation.py +41 -8
- agentkernel/memory.py +198 -27
- agentkernel/providers/__init__.py +25 -1
- agentkernel/providers/anthropic.py +19 -1
- agentkernel/providers/base.py +1 -0
- agentkernel/providers/compat.py +87 -0
- agentkernel/providers/local.py +4 -0
- agentkernel/providers/openai.py +22 -3
- agentkernel/roles.py +55 -0
- agentkernel/semantic_memory.py +20 -5
- agentkernel/skills.py +74 -0
- agentkernel/types.py +52 -0
- {agentkernel_cli-0.1.0.dist-info → agentkernel_cli-0.2.0.dist-info}/METADATA +20 -6
- {agentkernel_cli-0.1.0.dist-info → agentkernel_cli-0.2.0.dist-info}/RECORD +21 -19
- {agentkernel_cli-0.1.0.dist-info → agentkernel_cli-0.2.0.dist-info}/WHEEL +0 -0
- {agentkernel_cli-0.1.0.dist-info → agentkernel_cli-0.2.0.dist-info}/entry_points.txt +0 -0
- {agentkernel_cli-0.1.0.dist-info → agentkernel_cli-0.2.0.dist-info}/licenses/LICENSE +0 -0
agentkernel/agent.py
CHANGED
|
@@ -67,13 +67,15 @@ class Agent:
|
|
|
67
67
|
*,
|
|
68
68
|
profile: Any | None = None,
|
|
69
69
|
on_text: Any | None = None,
|
|
70
|
+
images: Any | None = None,
|
|
70
71
|
) -> str:
|
|
71
72
|
"""Drive the loop until a final answer or the max-iteration guard.
|
|
72
73
|
|
|
73
74
|
``profile`` (design §13, Phase 5) is accepted but, in the kernel, only
|
|
74
75
|
``tool_filter`` / ``system_prompt`` are honored if trivially present.
|
|
75
76
|
``on_text`` (when set) receives streamed text deltas; the loop contract is
|
|
76
|
-
otherwise unchanged.
|
|
77
|
+
otherwise unchanged. ``images`` (a list of ``ImageContent``) attaches to
|
|
78
|
+
the user turn; adapters that can't accept images ignore them (§18.6).
|
|
77
79
|
"""
|
|
78
80
|
session_id = getattr(self.telemetry, "session_id", str(uuid.uuid4()))
|
|
79
81
|
|
|
@@ -84,7 +86,11 @@ class Agent:
|
|
|
84
86
|
self.context.add(message)
|
|
85
87
|
|
|
86
88
|
self.context.add(
|
|
87
|
-
Message(
|
|
89
|
+
Message(
|
|
90
|
+
role="user",
|
|
91
|
+
content=self._prepare_user_message(user_input),
|
|
92
|
+
images=list(images) if images else [],
|
|
93
|
+
)
|
|
88
94
|
)
|
|
89
95
|
|
|
90
96
|
# Assemble the cacheable prefix ONCE per run and reuse the same objects
|
agentkernel/cli.py
CHANGED
|
@@ -20,7 +20,6 @@ import fnmatch
|
|
|
20
20
|
import json
|
|
21
21
|
import sys
|
|
22
22
|
from collections.abc import Callable
|
|
23
|
-
from dataclasses import replace
|
|
24
23
|
from pathlib import Path
|
|
25
24
|
|
|
26
25
|
from agentkernel.agent import Agent
|
|
@@ -35,6 +34,7 @@ from agentkernel.mcp.config import MCPServerConfig
|
|
|
35
34
|
from agentkernel.memory import (
|
|
36
35
|
MemoryStore,
|
|
37
36
|
NoteStore,
|
|
37
|
+
RecallWeighting,
|
|
38
38
|
make_memory_store,
|
|
39
39
|
make_memory_tools,
|
|
40
40
|
make_note_store,
|
|
@@ -43,16 +43,19 @@ from agentkernel.paths import agent_home, global_config_path
|
|
|
43
43
|
from agentkernel.profiles import Profile, load_profile
|
|
44
44
|
from agentkernel.progress import ProgressTelemetry
|
|
45
45
|
from agentkernel.providers import ProviderError, make_provider
|
|
46
|
+
from agentkernel.roles import provider_for_role, provider_with_model
|
|
46
47
|
from agentkernel.semantic_memory import SemanticSqliteNoteStore
|
|
47
48
|
from agentkernel.skills import DirectorySkillStore, make_skill_tool
|
|
48
49
|
from agentkernel.subagent import make_spawn_tool
|
|
49
50
|
from agentkernel.telemetry import JsonlTelemetry, NullTelemetry
|
|
50
51
|
from agentkernel.tools import ToolRegistry
|
|
51
52
|
from agentkernel.tools.builtin import default_tools
|
|
53
|
+
from agentkernel.types import ImageContent
|
|
52
54
|
|
|
53
55
|
_BANNER = (
|
|
54
56
|
"agentkernel REPL - type your message and press enter. Commands: /exit, "
|
|
55
|
-
"/clear, /system, /profile, /skills, /skill, /tools, /trace, /cost,
|
|
57
|
+
"/clear, /image, /system, /profile, /skills, /skill, /tools, /trace, /cost, "
|
|
58
|
+
"/memory, /improve."
|
|
56
59
|
)
|
|
57
60
|
_PROMPT = "> "
|
|
58
61
|
_EXIT_WORDS = {"exit", "quit", ":q"}
|
|
@@ -64,6 +67,12 @@ def _make_configured_note_store(config: Config) -> NoteStore:
|
|
|
64
67
|
Shared by build_runtime (memory tools) and run_memory (curation), so the
|
|
65
68
|
notebook backend is selected identically in both.
|
|
66
69
|
"""
|
|
70
|
+
weighting = RecallWeighting(
|
|
71
|
+
recency_weight=config.memory_recency_weight,
|
|
72
|
+
importance_weight=config.memory_importance_weight,
|
|
73
|
+
half_life_days=config.memory_half_life_days,
|
|
74
|
+
)
|
|
75
|
+
scope = _resolve_memory_scope(config)
|
|
67
76
|
if config.semantic_search:
|
|
68
77
|
try:
|
|
69
78
|
emb_provider = OpenAIEmbeddingProvider.from_config(config)
|
|
@@ -74,10 +83,26 @@ def _make_configured_note_store(config: Config) -> NoteStore:
|
|
|
74
83
|
notes_path,
|
|
75
84
|
embedding_provider=emb_provider,
|
|
76
85
|
lsh_bits=config.semantic_search_lsh_bits,
|
|
86
|
+
weighting=weighting,
|
|
87
|
+
scope=scope,
|
|
77
88
|
)
|
|
78
89
|
except EmbeddingError as exc:
|
|
79
90
|
print(f"Warning: semantic search disabled: {exc}", file=sys.stderr)
|
|
80
|
-
return make_note_store(config.memory_notes_path)
|
|
91
|
+
return make_note_store(config.memory_notes_path, weighting=weighting, scope=scope)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_memory_scope(config: Config) -> str | None:
|
|
95
|
+
"""Resolve ``config.memory_scope`` to the active namespace (or ``None`` = off).
|
|
96
|
+
|
|
97
|
+
``"auto"`` derives a stable name from the project directory; an empty value
|
|
98
|
+
disables scoping; any other string is used literally.
|
|
99
|
+
"""
|
|
100
|
+
raw = (config.memory_scope or "").strip()
|
|
101
|
+
if not raw:
|
|
102
|
+
return None
|
|
103
|
+
if raw.lower() == "auto":
|
|
104
|
+
return Path(config.working_dir or ".").resolve().name or None
|
|
105
|
+
return raw
|
|
81
106
|
|
|
82
107
|
|
|
83
108
|
def build_runtime(
|
|
@@ -189,9 +214,7 @@ def build_runtime(
|
|
|
189
214
|
budget_for_context = provider.context_window - config.output_reserve
|
|
190
215
|
summarizer = None
|
|
191
216
|
if config.summarizer_model:
|
|
192
|
-
summarizer = ModelSummarizer(
|
|
193
|
-
make_provider(replace(config, model=config.summarizer_model))
|
|
194
|
-
)
|
|
217
|
+
summarizer = ModelSummarizer(provider_for_role(config, "summarize"))
|
|
195
218
|
context = ContextManager(
|
|
196
219
|
budget=budget_for_context,
|
|
197
220
|
keep_recent_turns=config.keep_recent_turns,
|
|
@@ -202,8 +225,7 @@ def build_runtime(
|
|
|
202
225
|
if config.approval_policy == "smart":
|
|
203
226
|
from agentkernel.approval.risk import RiskJudge
|
|
204
227
|
|
|
205
|
-
|
|
206
|
-
risk_judge = RiskJudge(make_provider(replace(config, model=judge_model)))
|
|
228
|
+
risk_judge = RiskJudge(provider_for_role(config, "classify"))
|
|
207
229
|
approver = CliApprover(
|
|
208
230
|
config.approval_policy,
|
|
209
231
|
allowlist=config.approval_allowlist,
|
|
@@ -265,6 +287,7 @@ def _handle_slash(
|
|
|
265
287
|
profile: Profile,
|
|
266
288
|
config: Config,
|
|
267
289
|
output_fn: Callable[[str], None],
|
|
290
|
+
staged_images: list[ImageContent] | None = None,
|
|
268
291
|
) -> bool:
|
|
269
292
|
"""Process a REPL slash command. Returns True if the line was handled."""
|
|
270
293
|
parts = line.split(None, 1)
|
|
@@ -279,6 +302,33 @@ def _handle_slash(
|
|
|
279
302
|
output_fn("[context cleared]")
|
|
280
303
|
return True
|
|
281
304
|
|
|
305
|
+
if cmd == "image":
|
|
306
|
+
target = arg.strip()
|
|
307
|
+
if not target or target == "list":
|
|
308
|
+
count = len(staged_images or [])
|
|
309
|
+
output_fn(
|
|
310
|
+
f"[{count} image(s) staged for the next message]"
|
|
311
|
+
if count
|
|
312
|
+
else "[no images staged] usage: /image <path-or-url> | /image clear"
|
|
313
|
+
)
|
|
314
|
+
return True
|
|
315
|
+
if target == "clear":
|
|
316
|
+
if staged_images is not None:
|
|
317
|
+
staged_images.clear()
|
|
318
|
+
output_fn("[staged images cleared]")
|
|
319
|
+
return True
|
|
320
|
+
try:
|
|
321
|
+
loaded = _load_images([target]) or []
|
|
322
|
+
except (OSError, ValueError) as exc:
|
|
323
|
+
output_fn(f"[image error] {exc}")
|
|
324
|
+
return True
|
|
325
|
+
if staged_images is not None:
|
|
326
|
+
staged_images.extend(loaded)
|
|
327
|
+
supported = getattr(agent._provider_for(profile), "supports_images", False)
|
|
328
|
+
warn = "" if supported else f" (note: provider {config.provider!r} can't accept images)"
|
|
329
|
+
output_fn(f"[staged image: {target}]{warn}")
|
|
330
|
+
return True
|
|
331
|
+
|
|
282
332
|
if cmd == "system":
|
|
283
333
|
if not arg:
|
|
284
334
|
output_fn("[system prompt cleared]")
|
|
@@ -435,6 +485,7 @@ def repl(
|
|
|
435
485
|
streaming = stream_fn is not None and getattr(cfg, "stream", True)
|
|
436
486
|
output_fn(_BANNER)
|
|
437
487
|
profile = Profile(name="default")
|
|
488
|
+
staged_images: list[ImageContent] = [] # attached to the next message, then cleared
|
|
438
489
|
while True:
|
|
439
490
|
try:
|
|
440
491
|
line = input_fn(_PROMPT).strip()
|
|
@@ -446,7 +497,7 @@ def repl(
|
|
|
446
497
|
if line.lower() in _EXIT_WORDS:
|
|
447
498
|
break
|
|
448
499
|
if line.startswith("/"):
|
|
449
|
-
if not _handle_slash(line, agent, profile, cfg, output_fn):
|
|
500
|
+
if not _handle_slash(line, agent, profile, cfg, output_fn, staged_images):
|
|
450
501
|
break
|
|
451
502
|
continue
|
|
452
503
|
streamed = {"any": False}
|
|
@@ -455,13 +506,18 @@ def repl(
|
|
|
455
506
|
_s["any"] = True
|
|
456
507
|
stream_fn(text) # type: ignore[misc]
|
|
457
508
|
|
|
509
|
+
images = list(staged_images) if staged_images else None
|
|
458
510
|
try:
|
|
459
511
|
answer = agent.run(
|
|
460
|
-
line,
|
|
512
|
+
line,
|
|
513
|
+
profile=profile,
|
|
514
|
+
on_text=on_text if streaming else None,
|
|
515
|
+
images=images,
|
|
461
516
|
)
|
|
462
517
|
except ProviderError as exc:
|
|
463
518
|
output_fn(f"[provider error] {exc}")
|
|
464
519
|
continue
|
|
520
|
+
staged_images.clear() # consumed by the message just sent
|
|
465
521
|
if streaming and streamed["any"]:
|
|
466
522
|
stream_fn("\n") # type: ignore[misc]
|
|
467
523
|
else:
|
|
@@ -469,6 +525,19 @@ def repl(
|
|
|
469
525
|
return 0
|
|
470
526
|
|
|
471
527
|
|
|
528
|
+
def _load_images(specs: list[str] | None) -> list[ImageContent] | None:
|
|
529
|
+
"""Build ImageContent from --image specs (http(s) URL or local file path)."""
|
|
530
|
+
if not specs:
|
|
531
|
+
return None
|
|
532
|
+
images: list[ImageContent] = []
|
|
533
|
+
for spec in specs:
|
|
534
|
+
if spec.startswith(("http://", "https://")):
|
|
535
|
+
images.append(ImageContent.from_url(spec))
|
|
536
|
+
else:
|
|
537
|
+
images.append(ImageContent.from_path(spec))
|
|
538
|
+
return images
|
|
539
|
+
|
|
540
|
+
|
|
472
541
|
def run_once(
|
|
473
542
|
agent: Agent,
|
|
474
543
|
prompt: str,
|
|
@@ -477,19 +546,29 @@ def run_once(
|
|
|
477
546
|
output_fn: Callable[[str], None] = print,
|
|
478
547
|
stream_fn: Callable[[str], None] | None = None,
|
|
479
548
|
config: Config | None = None,
|
|
549
|
+
images: list[ImageContent] | None = None,
|
|
480
550
|
) -> int:
|
|
481
551
|
"""Execute a single non-interactive turn and print (or stream) the answer."""
|
|
482
552
|
cfg = config or agent.config
|
|
483
553
|
streaming = stream_fn is not None and getattr(cfg, "stream", True)
|
|
484
554
|
streamed = {"any": False}
|
|
485
555
|
|
|
556
|
+
if images and not getattr(agent._provider_for(profile), "supports_images", False):
|
|
557
|
+
output_fn(
|
|
558
|
+
f"[warning] provider {cfg.provider!r} (model {cfg.model!r}) does not "
|
|
559
|
+
"accept images; sending text only"
|
|
560
|
+
)
|
|
561
|
+
|
|
486
562
|
def on_text(text: str) -> None:
|
|
487
563
|
streamed["any"] = True
|
|
488
564
|
stream_fn(text) # type: ignore[misc]
|
|
489
565
|
|
|
490
566
|
try:
|
|
491
567
|
answer = agent.run(
|
|
492
|
-
prompt,
|
|
568
|
+
prompt,
|
|
569
|
+
profile=profile,
|
|
570
|
+
on_text=on_text if streaming else None,
|
|
571
|
+
images=images,
|
|
493
572
|
)
|
|
494
573
|
except ProviderError as exc:
|
|
495
574
|
output_fn(f"[provider error] {exc}")
|
|
@@ -564,6 +643,106 @@ def run_new(
|
|
|
564
643
|
return 0
|
|
565
644
|
|
|
566
645
|
|
|
646
|
+
def run_completion(
|
|
647
|
+
shell: str, commands: list[str], *, output_fn: Callable[[str], None] = print
|
|
648
|
+
) -> int:
|
|
649
|
+
"""Print a shell completion script for ``agentkernel`` (§18.7).
|
|
650
|
+
|
|
651
|
+
Completes the subcommand at the first position and defers to file completion
|
|
652
|
+
afterwards. The command list is passed in from the live parser so the script
|
|
653
|
+
never drifts from the actual subcommands.
|
|
654
|
+
"""
|
|
655
|
+
words = " ".join(commands)
|
|
656
|
+
if shell == "bash":
|
|
657
|
+
script = f"""\
|
|
658
|
+
# agentkernel bash completion. Install: source <(agentkernel completion bash)
|
|
659
|
+
_agentkernel_completion() {{
|
|
660
|
+
local cur="${{COMP_WORDS[COMP_CWORD]}}"
|
|
661
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
662
|
+
COMPREPLY=( $(compgen -W "{words}" -- "$cur") )
|
|
663
|
+
else
|
|
664
|
+
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
665
|
+
fi
|
|
666
|
+
}}
|
|
667
|
+
complete -F _agentkernel_completion agentkernel
|
|
668
|
+
"""
|
|
669
|
+
elif shell == "zsh":
|
|
670
|
+
script = f"""\
|
|
671
|
+
#compdef agentkernel
|
|
672
|
+
# agentkernel zsh completion. Install: agentkernel completion zsh > ~/.zsh/_agentkernel
|
|
673
|
+
_agentkernel() {{
|
|
674
|
+
local -a commands
|
|
675
|
+
commands=({words})
|
|
676
|
+
if (( CURRENT == 2 )); then
|
|
677
|
+
compadd -- $commands
|
|
678
|
+
else
|
|
679
|
+
_files
|
|
680
|
+
fi
|
|
681
|
+
}}
|
|
682
|
+
_agentkernel "$@"
|
|
683
|
+
"""
|
|
684
|
+
elif shell == "fish":
|
|
685
|
+
lines = [
|
|
686
|
+
"# agentkernel fish completion. Install: "
|
|
687
|
+
"agentkernel completion fish > ~/.config/fish/completions/agentkernel.fish",
|
|
688
|
+
f'complete -c agentkernel -n "__fish_use_subcommand" -a "{words}"',
|
|
689
|
+
]
|
|
690
|
+
script = "\n".join(lines) + "\n"
|
|
691
|
+
else:
|
|
692
|
+
output_fn(f"[unsupported shell: {shell}] choose bash, zsh, or fish")
|
|
693
|
+
return 1
|
|
694
|
+
output_fn(script)
|
|
695
|
+
return 0
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def run_skill(
|
|
699
|
+
config: Config,
|
|
700
|
+
action: str,
|
|
701
|
+
target: str | None,
|
|
702
|
+
*,
|
|
703
|
+
out_path: str | None = None,
|
|
704
|
+
force: bool = False,
|
|
705
|
+
output_fn: Callable[[str], None] = print,
|
|
706
|
+
) -> int:
|
|
707
|
+
"""Manage shareable skill bundles: ``list`` / ``pack`` / ``install`` (§18.8)."""
|
|
708
|
+
from agentkernel.skills import SkillLibrary, install_skill, pack_skill
|
|
709
|
+
|
|
710
|
+
skills_dir = config.skills_dir
|
|
711
|
+
if action == "list":
|
|
712
|
+
library = SkillLibrary(skills_dir)
|
|
713
|
+
described = library.describe()
|
|
714
|
+
if not described:
|
|
715
|
+
output_fn(f"[no skills in {skills_dir}]")
|
|
716
|
+
return 0
|
|
717
|
+
for name, desc in described:
|
|
718
|
+
output_fn(f"- {name}: {desc}")
|
|
719
|
+
return 0
|
|
720
|
+
if action == "pack":
|
|
721
|
+
if not target:
|
|
722
|
+
output_fn("[pack] a skill name is required")
|
|
723
|
+
return 1
|
|
724
|
+
try:
|
|
725
|
+
archive = pack_skill(skills_dir, target, out_path=out_path)
|
|
726
|
+
except FileNotFoundError as exc:
|
|
727
|
+
output_fn(f"[pack] {exc}")
|
|
728
|
+
return 1
|
|
729
|
+
output_fn(f"[packed {target}: {archive}]")
|
|
730
|
+
return 0
|
|
731
|
+
if action == "install":
|
|
732
|
+
if not target:
|
|
733
|
+
output_fn("[install] an archive path is required")
|
|
734
|
+
return 1
|
|
735
|
+
try:
|
|
736
|
+
name = install_skill(target, skills_dir, force=force)
|
|
737
|
+
except (FileExistsError, ValueError, OSError) as exc:
|
|
738
|
+
output_fn(f"[install] {exc}")
|
|
739
|
+
return 1
|
|
740
|
+
output_fn(f"[installed skill {name!r} into {skills_dir}]")
|
|
741
|
+
return 0
|
|
742
|
+
output_fn(f"[unknown skill action: {action}] choose list, pack, or install")
|
|
743
|
+
return 1
|
|
744
|
+
|
|
745
|
+
|
|
567
746
|
_PROJECT_CONFIG_TEMPLATE = """\
|
|
568
747
|
# agentkernel project config. Global defaults live in ~/.agentkernel/config.toml
|
|
569
748
|
# (or $AGENTKERNEL_HOME); keys here override them for this project.
|
|
@@ -882,8 +1061,7 @@ def run_memory(
|
|
|
882
1061
|
from agentkernel.curation import MemoryCurator
|
|
883
1062
|
|
|
884
1063
|
notes = _make_configured_note_store(config)
|
|
885
|
-
|
|
886
|
-
provider = make_provider(replace(config, model=curator_model))
|
|
1064
|
+
provider = provider_for_role(config, "curate")
|
|
887
1065
|
curator = MemoryCurator(notes, provider)
|
|
888
1066
|
|
|
889
1067
|
if action == "consolidate":
|
|
@@ -1005,11 +1183,11 @@ def run_eval(
|
|
|
1005
1183
|
context_source=base_agent.context_source,
|
|
1006
1184
|
)
|
|
1007
1185
|
|
|
1186
|
+
# An explicit --judge-model (or config.judge_model) overrides; otherwise reuse
|
|
1187
|
+
# the agent's own provider rather than spinning up a duplicate.
|
|
1008
1188
|
judge_model = judge_model or config.judge_model
|
|
1009
1189
|
judge = (
|
|
1010
|
-
|
|
1011
|
-
if judge_model
|
|
1012
|
-
else base_agent.provider
|
|
1190
|
+
provider_with_model(config, judge_model) if judge_model else base_agent.provider
|
|
1013
1191
|
)
|
|
1014
1192
|
evaluator = Evaluator(
|
|
1015
1193
|
agent_factory, judge,
|
|
@@ -1196,6 +1374,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1196
1374
|
action="store_true",
|
|
1197
1375
|
help="run detached in the background; output goes to a file",
|
|
1198
1376
|
)
|
|
1377
|
+
run_parser.add_argument(
|
|
1378
|
+
"--image",
|
|
1379
|
+
action="append",
|
|
1380
|
+
metavar="PATH_OR_URL",
|
|
1381
|
+
help="attach an image to the prompt (repeatable); a local path or http(s) URL",
|
|
1382
|
+
)
|
|
1199
1383
|
improve_parser = subparsers.add_parser(
|
|
1200
1384
|
"improve", help="reflect on a session trace and write an improvement note"
|
|
1201
1385
|
)
|
|
@@ -1291,6 +1475,25 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1291
1475
|
"--force", action="store_true", help="overwrite if the target already exists"
|
|
1292
1476
|
)
|
|
1293
1477
|
|
|
1478
|
+
skill_parser = subparsers.add_parser(
|
|
1479
|
+
"skill", help="package and install shareable skill bundles"
|
|
1480
|
+
)
|
|
1481
|
+
skill_parser.add_argument(
|
|
1482
|
+
"skill_action", choices=("list", "pack", "install"), help="what to do"
|
|
1483
|
+
)
|
|
1484
|
+
skill_parser.add_argument(
|
|
1485
|
+
"target", nargs="?", help="skill name (pack) or bundle path (install)"
|
|
1486
|
+
)
|
|
1487
|
+
skill_parser.add_argument("--out", help="output path for `pack` (.skill.zip)")
|
|
1488
|
+
skill_parser.add_argument(
|
|
1489
|
+
"--force", action="store_true", help="overwrite on `install`"
|
|
1490
|
+
)
|
|
1491
|
+
|
|
1492
|
+
completion_parser = subparsers.add_parser(
|
|
1493
|
+
"completion", help="print a shell completion script (bash/zsh/fish)"
|
|
1494
|
+
)
|
|
1495
|
+
completion_parser.add_argument("shell", choices=("bash", "zsh", "fish"))
|
|
1496
|
+
|
|
1294
1497
|
args = parser.parse_args(argv)
|
|
1295
1498
|
command = getattr(args, "command", None) or "repl"
|
|
1296
1499
|
|
|
@@ -1306,6 +1509,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1306
1509
|
)
|
|
1307
1510
|
if command == "new":
|
|
1308
1511
|
return run_new(args.kind, args.name, force=args.force)
|
|
1512
|
+
if command == "completion":
|
|
1513
|
+
return run_completion(args.shell, sorted(subparsers.choices))
|
|
1309
1514
|
|
|
1310
1515
|
config, project_config_path = resolve_config(args.config, cwd=args.cwd or ".")
|
|
1311
1516
|
# The concrete config path to hand to subprocesses / MCP discovery.
|
|
@@ -1334,6 +1539,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1334
1539
|
return run_sessions(config, args.action, getattr(args, "session_id", None))
|
|
1335
1540
|
if command == "memory":
|
|
1336
1541
|
return run_memory(config, args.action, session=getattr(args, "session", None))
|
|
1542
|
+
if command == "skill":
|
|
1543
|
+
return run_skill(
|
|
1544
|
+
config,
|
|
1545
|
+
args.skill_action,
|
|
1546
|
+
args.target,
|
|
1547
|
+
out_path=getattr(args, "out", None),
|
|
1548
|
+
force=getattr(args, "force", False),
|
|
1549
|
+
)
|
|
1337
1550
|
if command == "cron":
|
|
1338
1551
|
return run_cron(config, args.action, args.rest)
|
|
1339
1552
|
if command == "kanban":
|
|
@@ -1467,8 +1680,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1467
1680
|
prompt = args.prompt
|
|
1468
1681
|
if args.file:
|
|
1469
1682
|
prompt = _read_prompt_file(args.file)
|
|
1683
|
+
images = _load_images(getattr(args, "image", None))
|
|
1470
1684
|
return run_once(
|
|
1471
|
-
agent,
|
|
1685
|
+
agent,
|
|
1686
|
+
prompt or "",
|
|
1687
|
+
profile=profile,
|
|
1688
|
+
stream_fn=stream_fn,
|
|
1689
|
+
config=config,
|
|
1690
|
+
images=images,
|
|
1472
1691
|
)
|
|
1473
1692
|
return repl(agent, config=config, stream_fn=stream_fn)
|
|
1474
1693
|
finally:
|
agentkernel/config.py
CHANGED
|
@@ -20,9 +20,10 @@ DEFAULT_CONFIG_FILE = "agentkernel.toml"
|
|
|
20
20
|
|
|
21
21
|
@dataclass
|
|
22
22
|
class Config:
|
|
23
|
-
provider: str = "anthropic" #
|
|
23
|
+
provider: str = "anthropic" # anthropic|openai|local|openrouter|deepseek|gemini
|
|
24
24
|
model: str = "claude-sonnet-4-6"
|
|
25
25
|
base_url: str | None = None # for local/OpenAI-compatible endpoints
|
|
26
|
+
local_supports_images: bool = False # local endpoint accepts image_url parts (§18.6)
|
|
26
27
|
max_output_tokens: int = 4096
|
|
27
28
|
output_reserve: int = 8192 # budget headroom for the reply
|
|
28
29
|
max_iterations: int = 25
|
|
@@ -62,6 +63,10 @@ class Config:
|
|
|
62
63
|
memory_auto_context_limit: int = 3 # max notes per auto-recall
|
|
63
64
|
memory_store_budget: int | None = None # max tokens to persist per session
|
|
64
65
|
memory_curator_model: str | None = None # cheap model for memory extract/consolidate
|
|
66
|
+
memory_scope: str | None = None # recall namespace: "auto" (project dir) | name | None=off
|
|
67
|
+
memory_recency_weight: float = 0.0 # boost recall of recently-created notes
|
|
68
|
+
memory_importance_weight: float = 0.0 # boost recall of often-recalled notes
|
|
69
|
+
memory_half_life_days: float = 30.0 # age (days) for a note's recency score to halve
|
|
65
70
|
semantic_search: bool = False # rank note recall with dense embeddings
|
|
66
71
|
embedding_model: str = "text-embedding-3-small"
|
|
67
72
|
embedding_dimensions: int | None = None # optional truncation (OpenAI only)
|
agentkernel/context/manager.py
CHANGED
|
@@ -24,6 +24,11 @@ from agentkernel.types import Message
|
|
|
24
24
|
if TYPE_CHECKING:
|
|
25
25
|
from agentkernel.providers import Provider
|
|
26
26
|
|
|
27
|
+
# Flat token charge per attached image (design §18.6). A rough upper-mid estimate
|
|
28
|
+
# for a typical tiled image; cheaper than measuring the base64 payload and avoids
|
|
29
|
+
# wildly over-counting an inline data URI.
|
|
30
|
+
IMAGE_TOKEN_ESTIMATE = 1000
|
|
31
|
+
|
|
27
32
|
# A summarizer turns a list of (old) messages into one summary string. The
|
|
28
33
|
# default is the deterministic structural fallback below; a model-based
|
|
29
34
|
# summarizer can be injected here (design §9.2).
|
|
@@ -40,13 +45,19 @@ class CompactionEvent:
|
|
|
40
45
|
|
|
41
46
|
|
|
42
47
|
def estimate_tokens(message: Message) -> int:
|
|
43
|
-
"""Conservative chars/4 estimate for one message (design §9.1).
|
|
48
|
+
"""Conservative chars/4 estimate for one message (design §9.1).
|
|
49
|
+
|
|
50
|
+
Images are not text, so they are charged a flat per-image estimate rather
|
|
51
|
+
than by their (large) base64 length — close enough to keep the budget honest
|
|
52
|
+
without over-counting an inline data URI.
|
|
53
|
+
"""
|
|
44
54
|
chars = len(message.content or "")
|
|
45
55
|
for tc in message.tool_calls:
|
|
46
56
|
chars += len(tc.name) + len(json.dumps(tc.arguments))
|
|
47
57
|
for r in message.tool_results:
|
|
48
58
|
chars += len(r.content or "")
|
|
49
|
-
|
|
59
|
+
image_tokens = len(message.images) * IMAGE_TOKEN_ESTIMATE
|
|
60
|
+
return max(1, chars // CHARS_PER_TOKEN + image_tokens)
|
|
50
61
|
|
|
51
62
|
|
|
52
63
|
def structural_summary(messages: list[Message]) -> str:
|
agentkernel/curation.py
CHANGED
|
@@ -136,26 +136,59 @@ class MemoryCurator:
|
|
|
136
136
|
existing = self._notes.all()
|
|
137
137
|
if len(existing) < 2:
|
|
138
138
|
return ConsolidationResult(len(existing), len(existing), existing)
|
|
139
|
+
# Consolidate within each namespace so distinct scopes are never merged
|
|
140
|
+
# together and every rebuilt note keeps its original scope. Without this,
|
|
141
|
+
# rebuilding via add() would re-stamp every note with the store's active
|
|
142
|
+
# scope, collapsing global and other-project notes into one namespace.
|
|
143
|
+
groups: dict[str, list[MemoryNote]] = {}
|
|
144
|
+
for note in existing:
|
|
145
|
+
groups.setdefault(note.scope, []).append(note)
|
|
146
|
+
|
|
147
|
+
new_notes: list[MemoryNote] = []
|
|
148
|
+
changed = False
|
|
149
|
+
for scope, group in groups.items():
|
|
150
|
+
rebuilt = self._consolidate_group(scope, group)
|
|
151
|
+
if rebuilt is None: # no-op for this group (too small or unparseable)
|
|
152
|
+
new_notes.extend(group)
|
|
153
|
+
else:
|
|
154
|
+
new_notes.extend(rebuilt)
|
|
155
|
+
changed = True
|
|
156
|
+
if not changed:
|
|
157
|
+
return ConsolidationResult(len(existing), len(existing), existing)
|
|
158
|
+
return ConsolidationResult(len(existing), len(new_notes), new_notes)
|
|
159
|
+
|
|
160
|
+
def _consolidate_group(
|
|
161
|
+
self, scope: str, group: list[MemoryNote]
|
|
162
|
+
) -> list[MemoryNote] | None:
|
|
163
|
+
"""Consolidate one namespace's notes, preserving ``scope``.
|
|
164
|
+
|
|
165
|
+
Returns the rebuilt notes, or ``None`` to signal a no-op (fewer than two
|
|
166
|
+
notes, or an empty/unparseable model reply) so the caller keeps the
|
|
167
|
+
originals untouched.
|
|
168
|
+
"""
|
|
169
|
+
if len(group) < 2:
|
|
170
|
+
return None
|
|
139
171
|
listing = "\n".join(
|
|
140
172
|
f"{n.note_id}. {n.text}"
|
|
141
173
|
+ (f" [tags: {', '.join(n.tags)}]" if n.tags else "")
|
|
142
|
-
for n in
|
|
174
|
+
for n in group
|
|
143
175
|
)
|
|
144
176
|
cleaned = [
|
|
145
177
|
c for c in self._ask(_CONSOLIDATE_SYSTEM, f"Current memory notes:\n{listing}")
|
|
146
178
|
if str(c.get("text", "")).strip()
|
|
147
179
|
]
|
|
148
180
|
if not cleaned:
|
|
149
|
-
return
|
|
150
|
-
# Rebuild
|
|
151
|
-
#
|
|
152
|
-
for note in
|
|
181
|
+
return None
|
|
182
|
+
# Rebuild this group using the store's public API (so all backends stay
|
|
183
|
+
# consistent), re-stamping each note with the group's original scope.
|
|
184
|
+
for note in group:
|
|
153
185
|
self._notes.forget(note_id=note.note_id)
|
|
154
|
-
|
|
155
|
-
self._notes.add(
|
|
186
|
+
return [
|
|
187
|
+
self._notes.add(
|
|
188
|
+
str(c["text"]).strip(), tags=c.get("tags") or [], scope=scope
|
|
189
|
+
)
|
|
156
190
|
for c in cleaned
|
|
157
191
|
]
|
|
158
|
-
return ConsolidationResult(len(existing), len(new_notes), new_notes)
|
|
159
192
|
|
|
160
193
|
# --- internals ---------------------------------------------------------
|
|
161
194
|
|