codee-agent 0.1.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.
- codee/.gitignore +2 -0
- codee/__init__.py +0 -0
- codee/admin.py +1672 -0
- codee/admin_api.py +61 -0
- codee/admin_cli.py +86 -0
- codee/admin_service.py +1005 -0
- codee/executor.py +381 -0
- codee/init_cli.py +82 -0
- codee/lib/__init__.py +0 -0
- codee/lib/cron_describe.py +33 -0
- codee/lib/runs_db.py +195 -0
- codee/lib/test_runs_db.py +199 -0
- codee/lib/test_trigger_cron_skills.py +485 -0
- codee/lib/test_trigger_issue_skills.py +93 -0
- codee/lib/trigger_aws_sqs_skills.py +224 -0
- codee/lib/trigger_cron_skills.py +364 -0
- codee/lib/trigger_email_skills.py +225 -0
- codee/lib/trigger_issue_skills.py +107 -0
- codee/mail_server.py +45 -0
- codee/start_cli.py +98 -0
- codee/templates/AGENTS.md +42 -0
- codee/templates/CLAUDE.md +1 -0
- codee/templates/skills/aws-sqs-alarm-response/SKILL.md +23 -0
- codee/templates/skills/cron-research-5xx-errors/SKILL.md +17 -0
- codee/templates/skills/story-code-reviewer/SKILL.md +29 -0
- codee/templates/skills/story-developer/SKILL.md +26 -0
- codee/templates/skills/story-planner/SKILL.md +35 -0
- codee/templates/skills/story-planner/assets/readme-template.md +43 -0
- codee/templates/skills/story-qa/SKILL.md +28 -0
- codee/templates/skills/task-developer/SKILL.md +25 -0
- codee/templates/skills/task-qa/SKILL.md +26 -0
- codee/test_admin_api.py +64 -0
- codee/test_admin_cli.py +63 -0
- codee/test_admin_service.py +897 -0
- codee/test_executor.py +190 -0
- codee/test_init_cli.py +131 -0
- codee/test_memory_index.py +31 -0
- codee/test_start_cli.py +164 -0
- codee/workflow_graph.py +83 -0
- codee_admin/__init__.py +1 -0
- codee_admin/codee_admin.py +4 -0
- codee_agent-0.1.0.dist-info/METADATA +66 -0
- codee_agent-0.1.0.dist-info/RECORD +69 -0
- codee_agent-0.1.0.dist-info/WHEEL +4 -0
- codee_agent-0.1.0.dist-info/entry_points.txt +6 -0
- codee_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- codee_agent_abstract/__init__.py +0 -0
- codee_agent_abstract/provider.py +56 -0
- codee_agent_claude_code/__init__.py +0 -0
- codee_agent_claude_code/provider.py +90 -0
- codee_agent_github_copilot/__init__.py +0 -0
- codee_agent_github_copilot/provider.py +253 -0
- codee_agent_github_copilot/test.py +176 -0
- codee_database/__init__.py +0 -0
- codee_database/database.py +13 -0
- codee_database/oauth_tokens.py +148 -0
- codee_main_context/__init__.py +0 -0
- codee_main_context/context.py +127 -0
- codee_main_context/logging.py +111 -0
- codee_main_context/test_logging.py +90 -0
- codee_tasks_abstract/__init__.py +0 -0
- codee_tasks_abstract/provider.py +58 -0
- codee_tasks_azure_devops/__init__.py +0 -0
- codee_tasks_azure_devops/oauth.py +346 -0
- codee_tasks_azure_devops/provider.py +207 -0
- codee_tasks_azure_devops/test.py +462 -0
- codee_tasks_jira/__init__.py +0 -0
- codee_tasks_jira/provider.py +162 -0
- codee_tasks_jira/test.py +75 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
codee/.gitignore,sha256=Fltsn5J90L9HwYTVg7Xlaoek3U0riZWmDTWmf0Ttfr8,15
|
|
2
|
+
codee/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
codee/admin.py,sha256=nL_zv7XuO1gSND25AAVrBMRfDd042LLiM5MXgGp2-CY,71801
|
|
4
|
+
codee/admin_api.py,sha256=q2S2H6neSXkcjrlhI3s190jJbOgenL0sqle5vnYmM5E,2503
|
|
5
|
+
codee/admin_cli.py,sha256=V57mVyxDPPCW3IP715Fnejfp_o7H8XXiADTKLTAh-fY,2461
|
|
6
|
+
codee/admin_service.py,sha256=hs8hNLBeNg5wjkIndTzYRnw2MEpKrBpHeUu-uK-_lFs,42135
|
|
7
|
+
codee/executor.py,sha256=t30M0svaRfCP3CI1ES0FNEafu0H-Q70UR-_9tCgw71Q,14938
|
|
8
|
+
codee/init_cli.py,sha256=0i_epmF5S2PeKNwHgRIvrjY-yn3V2sGiTsI3O5ha0tQ,2858
|
|
9
|
+
codee/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
codee/lib/cron_describe.py,sha256=OTq58sKafyEz23WsZJdlxO0mOf944X4sXtNqY0pkNq4,960
|
|
11
|
+
codee/lib/runs_db.py,sha256=YBL30GBucK6e5zHEDQYLdqCnuq0IX4A4ZJDpNdd_WwE,8350
|
|
12
|
+
codee/lib/test_runs_db.py,sha256=ILZ_Dv3GW6HNNE7reKbZ8kW--nBbwfw2MpaPO_K_VU8,8225
|
|
13
|
+
codee/lib/test_trigger_cron_skills.py,sha256=hLnJHETzO9vHP_dUws9i_abAUNOxv4eqOadaAQefJqk,18482
|
|
14
|
+
codee/lib/test_trigger_issue_skills.py,sha256=1tHA-aP28HEQEKxsYKaKXn-c5dn_TJirg7_Y-wevIFI,3675
|
|
15
|
+
codee/lib/trigger_aws_sqs_skills.py,sha256=n3r1zEgOiywPEsWtDiLpGZOpsQM49mfQREh6wJ7Myyo,7178
|
|
16
|
+
codee/lib/trigger_cron_skills.py,sha256=GnkG4ED9doONDRGyjaMF9BgRO9dC2dhGRZ7IhsdZuRw,12722
|
|
17
|
+
codee/lib/trigger_email_skills.py,sha256=1-eX__ZMCTzljzcTN34RCvU8-0Q4H67CFEW5_HlD_vM,8095
|
|
18
|
+
codee/lib/trigger_issue_skills.py,sha256=MXQMR23L1HN-c4a9dFvqFbY9BtVeyFdVX8f9kzc8R-w,3630
|
|
19
|
+
codee/mail_server.py,sha256=vqlObtIo59zjsTknWTdFhXJMOOG5bBbc5JMm4TDX1IU,1691
|
|
20
|
+
codee/start_cli.py,sha256=-YHCM3VHb1leZhH9lYOiJnMshGOJOlq7sTxfOrtzTB0,2940
|
|
21
|
+
codee/templates/AGENTS.md,sha256=NXJIXxo7_91jO47IdXxM-w5uSDjhPWeJryMy7_R6c1I,1815
|
|
22
|
+
codee/templates/CLAUDE.md,sha256=kYsckFWTVe948z_fNWLysCHvi9_YpchBXl3s1Ek03lU,10
|
|
23
|
+
codee/templates/skills/aws-sqs-alarm-response/SKILL.md,sha256=HGs1vAqIa2hxU94SS8lTmxaUnId3Wf9usAnqawtY8yE,904
|
|
24
|
+
codee/templates/skills/cron-research-5xx-errors/SKILL.md,sha256=h131nDVkndvg9HUfWaUC1sTyynDp56IVLYH2yti41Ho,704
|
|
25
|
+
codee/templates/skills/story-code-reviewer/SKILL.md,sha256=t8iADhFqrd5TvsZ849n1mPNSQT4ujwwtP7zF7uE9-oU,1311
|
|
26
|
+
codee/templates/skills/story-developer/SKILL.md,sha256=pndrmcfMhM5ubtkW0lCxgoqMtYHvro8oBbFv0DlKtyk,1398
|
|
27
|
+
codee/templates/skills/story-planner/SKILL.md,sha256=IKHvGuy9EUplYgkPuZts4edjTcmWwk6SptVA8C-n_5g,1871
|
|
28
|
+
codee/templates/skills/story-planner/assets/readme-template.md,sha256=kIzyB8yk9zA5SWIhQb2XhuvXDBbG6vxmzOMQAgCkukw,793
|
|
29
|
+
codee/templates/skills/story-qa/SKILL.md,sha256=-W7g8g4mePkCV5-mqt42_qwo6RPWrD_huQVHpadbcWs,1614
|
|
30
|
+
codee/templates/skills/task-developer/SKILL.md,sha256=CKMLv-YDKmXhwZV8c6Ufjpy0QFJ0nEM4WI8yBdv7t4E,1488
|
|
31
|
+
codee/templates/skills/task-qa/SKILL.md,sha256=ihdRKhSoZfupVXrclZIhILaAVMmGRIRaOuSmYwgg9sE,1395
|
|
32
|
+
codee/test_admin_api.py,sha256=eT-ZCWw8giVU5V1xdqdctytVjuXV3wm3FKv4Nnp3gaU,2710
|
|
33
|
+
codee/test_admin_cli.py,sha256=5AWSdeSgsY-RuFmP-ol5NDASvlelVhJXPZiQatSALrk,2350
|
|
34
|
+
codee/test_admin_service.py,sha256=ESz89Tva0U9VzjUSrGZEowssLDSLFEL6QLqDf1Jwj-g,42706
|
|
35
|
+
codee/test_executor.py,sha256=K5p_KWd9JC0uE0UXSge--rmSh7UjMGYtzBRfoQl-Tz4,7312
|
|
36
|
+
codee/test_init_cli.py,sha256=1POSbxbmOFf1mRLBEMgXDrr7eWQ6k8S02_ahqLjQJm8,5606
|
|
37
|
+
codee/test_memory_index.py,sha256=8Q2wq2l-L53QBDqzAeGaTQAaK4aCI4zo7WZND7byUZw,841
|
|
38
|
+
codee/test_start_cli.py,sha256=04Bcr-WH2gAfptii-6NgjEs7xxEFDqoNB0CJP0U58DM,5942
|
|
39
|
+
codee/workflow_graph.py,sha256=_R8TvXLQr0rtrELcPoNMMYTe4Wr9CRB_hRDw8imIl_4,2539
|
|
40
|
+
codee_admin/__init__.py,sha256=kdPfIMlHX4kajFVw6XmC2jqsw1XKkkfEGWWC_3bdDZE,51
|
|
41
|
+
codee_admin/codee_admin.py,sha256=MVzRzfRsMYWkcaD3zQG8bDmvs5FTrUSFz1Ij-_wU8e4,93
|
|
42
|
+
codee_agent_abstract/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
43
|
+
codee_agent_abstract/provider.py,sha256=lUYAuLTasdIwWc3Hw43ivYNTncTVRBq0xjMBVL19X2Y,1949
|
|
44
|
+
codee_agent_claude_code/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
45
|
+
codee_agent_claude_code/provider.py,sha256=ktD2idOc5cMb5h4yE-TQEc80L1iA7_bg3KPEzwefreU,3380
|
|
46
|
+
codee_agent_github_copilot/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
47
|
+
codee_agent_github_copilot/provider.py,sha256=jJm0vOyj6VfoCUUqup0tsTtYHgUmov1Hef7Eh_H54qQ,9392
|
|
48
|
+
codee_agent_github_copilot/test.py,sha256=VXMSmx-wl-pD2hh3bS3SyTjjxiUpuaYTOBkVt9uXJ_Y,7085
|
|
49
|
+
codee_database/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
50
|
+
codee_database/database.py,sha256=qoIemJhiOfZEaoN3AspFaLmfAXZkCm8bgctuUs9MKwE,358
|
|
51
|
+
codee_database/oauth_tokens.py,sha256=x3UOhfcc3qr-qnedhI9eGyvkUx1MsagoIy70JH7wuWM,5971
|
|
52
|
+
codee_main_context/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
|
+
codee_main_context/context.py,sha256=a4vl_upntRenMByuv8nr7VfQX_zLpeDFtSn2OGyRzhE,4583
|
|
54
|
+
codee_main_context/logging.py,sha256=dZk3RnYrKh12TXpG_3G-4v1ky4KXLPA7oj-_hmjU190,3742
|
|
55
|
+
codee_main_context/test_logging.py,sha256=5-GVjjmUNH320lc7_6YZBwIPav4--PfnSY1_QU_4c4Q,3543
|
|
56
|
+
codee_tasks_abstract/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
57
|
+
codee_tasks_abstract/provider.py,sha256=Jpv0FilAumNWDQkilJbrU5C88ddcD0LakYSvCWdxwk8,1923
|
|
58
|
+
codee_tasks_azure_devops/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
59
|
+
codee_tasks_azure_devops/oauth.py,sha256=gUaaFid32UxFYR9mULg3tDI4Pq1ij4pauLmxbF-6itE,13302
|
|
60
|
+
codee_tasks_azure_devops/provider.py,sha256=m83Qx79KvL_m9AxAmDS1_hZIREpJLmsiXJrgvF5WvMs,8922
|
|
61
|
+
codee_tasks_azure_devops/test.py,sha256=zvYi2dfILEENB6nMNfG1kMiskOACDaCCHFlDLlVXjkk,20355
|
|
62
|
+
codee_tasks_jira/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
63
|
+
codee_tasks_jira/provider.py,sha256=enIlKVsv50KDvAK37JFtQHiqh1dButKe4a9j0S837AU,6155
|
|
64
|
+
codee_tasks_jira/test.py,sha256=S-k51HdQD4qnp6qeTi73QSLFzvObmYZLPEMTOCq5jpw,2425
|
|
65
|
+
codee_agent-0.1.0.dist-info/licenses/LICENSE,sha256=kXz80_Kc5IXrxIiweSCMx9Dv_AUeOcfwnQqCk2IDTmY,1075
|
|
66
|
+
codee_agent-0.1.0.dist-info/WHEEL,sha256=a0ta4TK7UKMB0EbDesmi2mn0Djkly7aj_7Tu2R7SseE,81
|
|
67
|
+
codee_agent-0.1.0.dist-info/entry_points.txt,sha256=hTLMHLPAf2JRk3wwy-t3R5oqM4AlwQCJy2H7-MjA1xQ,159
|
|
68
|
+
codee_agent-0.1.0.dist-info/METADATA,sha256=5sXrClMO-uf8culqG6266h_cFUBNTw6ckQLDJ-33_-k,2304
|
|
69
|
+
codee_agent-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Codee contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from codee_main_context.context import Settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class AgentModel:
|
|
10
|
+
"""One model a coding agent can run, as the admin UI shows and stores it.
|
|
11
|
+
|
|
12
|
+
``id`` is what goes into the skill's ``model:`` frontmatter and onto the CLI;
|
|
13
|
+
``name`` is the human-readable label ("Claude Opus 5" for ``claude-opus-5``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
name: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AbstractCodingAgent(ABC):
|
|
21
|
+
"""Base class every coding agent (e.g. Claude Code) inherits from.
|
|
22
|
+
|
|
23
|
+
A coding agent is constructed from the app ``Settings`` and the working
|
|
24
|
+
directory it should run in, and knows how to run one prompt under a session
|
|
25
|
+
id the caller hands it. The executor never has to know which agent it's
|
|
26
|
+
driving or what configuration that agent needs.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, settings: Settings, cwd: Path):
|
|
30
|
+
self._cwd = cwd
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def run(self, user_message: str, session_id: str, model: str = "") -> str:
|
|
34
|
+
"""Run the agent with the message in ``session_id`` and return its text.
|
|
35
|
+
|
|
36
|
+
``model`` is the skill's ``model:`` frontmatter, or empty for the agent's
|
|
37
|
+
default. Agents that read the frontmatter themselves may ignore it.
|
|
38
|
+
|
|
39
|
+
Must raise on any failure so callers can retry (SQS keeps the message,
|
|
40
|
+
cron doesn't mark the slot done, email keeps the ``.eml``).
|
|
41
|
+
"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def list_models(cls) -> list[AgentModel]:
|
|
46
|
+
"""Models this agent offers, best-effort, for the admin UI's picker.
|
|
47
|
+
|
|
48
|
+
Returns an empty list when the agent can't be asked. Callers must still
|
|
49
|
+
accept a model id typed by hand, since the list is a convenience rather
|
|
50
|
+
than an allowlist.
|
|
51
|
+
"""
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
def describe(self) -> str:
|
|
55
|
+
"""Human-readable one-liner about this agent, for logs."""
|
|
56
|
+
return type(self).__name__
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from codee_agent_abstract.provider import AbstractCodingAgent, AgentModel
|
|
6
|
+
from codee_main_context.context import Settings
|
|
7
|
+
from codee_main_context.logging import get_logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
log = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
# The `claude` CLI has no command that enumerates its models, so this catalog is
|
|
13
|
+
# maintained by hand. It only feeds the admin UI's picker — the editor also takes
|
|
14
|
+
# a model id typed by hand, so a model missing here is still usable.
|
|
15
|
+
MODELS = [
|
|
16
|
+
AgentModel("claude-opus-5", "Claude Opus 5"),
|
|
17
|
+
AgentModel("claude-sonnet-5", "Claude Sonnet 5"),
|
|
18
|
+
AgentModel("claude-fable-5", "Claude Fable 5"),
|
|
19
|
+
AgentModel("claude-opus-4-8", "Claude Opus 4.8"),
|
|
20
|
+
AgentModel("claude-opus-4-7", "Claude Opus 4.7"),
|
|
21
|
+
AgentModel("claude-opus-4-6", "Claude Opus 4.6"),
|
|
22
|
+
AgentModel("claude-sonnet-4-6", "Claude Sonnet 4.6"),
|
|
23
|
+
AgentModel("claude-haiku-4-5", "Claude Haiku 4.5"),
|
|
24
|
+
AgentModel("opus", "Latest Opus (alias)"),
|
|
25
|
+
AgentModel("sonnet", "Latest Sonnet (alias)"),
|
|
26
|
+
AgentModel("haiku", "Latest Haiku (alias)"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ClaudeCodeAgent(AbstractCodingAgent):
|
|
31
|
+
"""Runs the ``claude`` CLI in a fresh session, under the id the caller supplies."""
|
|
32
|
+
|
|
33
|
+
MAX_BUDGET_USD = "20.00"
|
|
34
|
+
TIMEOUT_SECONDS = 7200 # 2 hours
|
|
35
|
+
|
|
36
|
+
def __init__(self, settings: Settings, cwd: Path):
|
|
37
|
+
super().__init__(settings, cwd)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def list_models(cls) -> list[AgentModel]:
|
|
41
|
+
return list(MODELS)
|
|
42
|
+
|
|
43
|
+
def run(self, user_message: str, session_id: str, model: str = "") -> str:
|
|
44
|
+
# `model` is deliberately not forwarded as --model: Claude Code reads the
|
|
45
|
+
# skill's `model:` frontmatter itself.
|
|
46
|
+
cmd = [
|
|
47
|
+
"claude",
|
|
48
|
+
"-p", user_message,
|
|
49
|
+
"--session-id", session_id,
|
|
50
|
+
"--max-budget-usd", self.MAX_BUDGET_USD,
|
|
51
|
+
"--output-format", "json",
|
|
52
|
+
"--permission-mode", "bypassPermissions",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
log.info("Running claude with message: %s", user_message)
|
|
56
|
+
log.debug("cwd=%s cmd=%s", self._cwd, " ".join(cmd))
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
cmd,
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
timeout=self.TIMEOUT_SECONDS,
|
|
64
|
+
cwd=self._cwd,
|
|
65
|
+
)
|
|
66
|
+
except subprocess.TimeoutExpired:
|
|
67
|
+
raise RuntimeError("Claude CLI timed out after 2 hours")
|
|
68
|
+
|
|
69
|
+
log.debug("claude exited %d (%d bytes stdout, %d bytes stderr)",
|
|
70
|
+
result.returncode, len(result.stdout), len(result.stderr))
|
|
71
|
+
|
|
72
|
+
# Raise on any non-success so callers retry. Over-limit exits non-zero;
|
|
73
|
+
# a completed-but-errored run sets is_error in the JSON.
|
|
74
|
+
if result.returncode != 0:
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
f"Claude CLI exited {result.returncode}: {result.stderr.strip()[:500]}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
response = json.loads(result.stdout)
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
return result.stdout
|
|
83
|
+
if isinstance(response, dict):
|
|
84
|
+
if response.get("is_error"):
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
f"Claude run errored ({response.get('subtype', 'unknown')}): "
|
|
87
|
+
f"{str(response.get('result', ''))[:500]}"
|
|
88
|
+
)
|
|
89
|
+
return response.get("result", result.stdout)
|
|
90
|
+
return result.stdout
|
|
File without changes
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import queue
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from codee_agent_abstract.provider import AbstractCodingAgent, AgentModel
|
|
9
|
+
from codee_main_context.context import Settings
|
|
10
|
+
from codee_main_context.logging import get_logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
log = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
# Events the CLI streams that carry the pieces we care about. Everything else in
|
|
16
|
+
# the JSONL stream (tool calls, MCP chatter, usage checkpoints) is skipped.
|
|
17
|
+
ASSISTANT_MESSAGE = "assistant.message"
|
|
18
|
+
SESSION_ERROR = "session.error"
|
|
19
|
+
RESULT = "result"
|
|
20
|
+
|
|
21
|
+
# `copilot` has no "list models" command, but its Agent Client Protocol mode
|
|
22
|
+
# answers session/new with the account's live catalog — ids and display names
|
|
23
|
+
# both. That's the only way to ask the CLI what it can run.
|
|
24
|
+
ACP_TIMEOUT_SECONDS = 60
|
|
25
|
+
_INITIALIZE_ID = 1
|
|
26
|
+
_SESSION_NEW_ID = 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GitHubCopilotAgent(AbstractCodingAgent):
|
|
30
|
+
"""Runs the ``copilot`` CLI in a fresh session, under the id the caller supplies."""
|
|
31
|
+
|
|
32
|
+
# Ceiling on what one run may spend. AI credits bill at $0.04 each, so this
|
|
33
|
+
# is the same $20 cap the Claude Code agent puts on a run. Minimum is 30.
|
|
34
|
+
MAX_AI_CREDITS = "500"
|
|
35
|
+
TIMEOUT_SECONDS = 7200 # 2 hours
|
|
36
|
+
|
|
37
|
+
def __init__(self, settings: Settings, cwd: Path):
|
|
38
|
+
super().__init__(settings, cwd)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def list_models(cls) -> list[AgentModel]:
|
|
42
|
+
try:
|
|
43
|
+
return _fetch_acp_models()
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
# The picker falls back to free text, so a CLI that isn't installed
|
|
46
|
+
# or isn't logged in must not break the admin UI.
|
|
47
|
+
log.warning("Could not read the copilot model catalog: %s", exc)
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
def run(self, user_message: str, session_id: str, model: str = "") -> str:
|
|
51
|
+
cmd = [
|
|
52
|
+
"copilot",
|
|
53
|
+
"-p", user_message,
|
|
54
|
+
"--session-id", session_id,
|
|
55
|
+
"--max-ai-credits", self.MAX_AI_CREDITS,
|
|
56
|
+
"--output-format", "json",
|
|
57
|
+
# Tools, paths and URLs: the equivalent of claude's bypassPermissions.
|
|
58
|
+
"--allow-all",
|
|
59
|
+
# Nothing is around to answer questions in a headless run.
|
|
60
|
+
"--no-ask-user",
|
|
61
|
+
"--no-color",
|
|
62
|
+
]
|
|
63
|
+
# Copilot never sees the skill's frontmatter — the triggers hand it the
|
|
64
|
+
# body alone — so a skill's model only takes effect via this flag.
|
|
65
|
+
if model:
|
|
66
|
+
cmd += ["--model", model]
|
|
67
|
+
|
|
68
|
+
log.info("Running copilot with message: %s", user_message)
|
|
69
|
+
log.debug("cwd=%s cmd=%s", self._cwd, " ".join(cmd))
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
cmd,
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
timeout=self.TIMEOUT_SECONDS,
|
|
77
|
+
cwd=self._cwd,
|
|
78
|
+
)
|
|
79
|
+
except subprocess.TimeoutExpired:
|
|
80
|
+
raise RuntimeError("Copilot CLI timed out after 2 hours")
|
|
81
|
+
|
|
82
|
+
log.debug("copilot exited %d (%d bytes stdout, %d bytes stderr)",
|
|
83
|
+
result.returncode, len(result.stdout), len(result.stderr))
|
|
84
|
+
|
|
85
|
+
reply, outcome, errors = _parse_events(result.stdout)
|
|
86
|
+
|
|
87
|
+
# Raise on any non-success so callers retry. A run that fails before the
|
|
88
|
+
# session starts (bad model, no auth) exits non-zero with nothing on
|
|
89
|
+
# stdout; one that fails mid-run reports it in the trailing result event.
|
|
90
|
+
if result.returncode != 0:
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
f"Copilot CLI exited {result.returncode}: "
|
|
93
|
+
f"{_detail(errors, result.stderr)}"
|
|
94
|
+
)
|
|
95
|
+
if outcome is None:
|
|
96
|
+
# Not the JSONL we know how to read — hand back whatever it printed
|
|
97
|
+
# rather than failing a run that the CLI itself called successful.
|
|
98
|
+
log.warning("copilot produced no result event; returning raw output")
|
|
99
|
+
return result.stdout
|
|
100
|
+
if outcome.get("exitCode"):
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
f"Copilot run errored (exit code {outcome['exitCode']}): "
|
|
103
|
+
f"{_detail(errors, result.stderr)}"
|
|
104
|
+
)
|
|
105
|
+
if not reply:
|
|
106
|
+
raise RuntimeError(
|
|
107
|
+
f"Copilot run produced no response: {_detail(errors, result.stderr)}"
|
|
108
|
+
)
|
|
109
|
+
return reply
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _fetch_acp_models() -> list[AgentModel]:
|
|
113
|
+
"""Ask ``copilot --acp`` for the account's model catalog.
|
|
114
|
+
|
|
115
|
+
Speaks just enough of the Agent Client Protocol to get a session: initialize,
|
|
116
|
+
then session/new, whose result carries ``models.availableModels``. The session
|
|
117
|
+
it opens is a throwaway that no prompt is ever sent to.
|
|
118
|
+
"""
|
|
119
|
+
process = subprocess.Popen(
|
|
120
|
+
["copilot", "--acp"],
|
|
121
|
+
stdin=subprocess.PIPE,
|
|
122
|
+
stdout=subprocess.PIPE,
|
|
123
|
+
stderr=subprocess.PIPE,
|
|
124
|
+
text=True,
|
|
125
|
+
bufsize=1,
|
|
126
|
+
cwd=Path.cwd(),
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
lines: queue.Queue[str] = queue.Queue()
|
|
130
|
+
reader = threading.Thread(
|
|
131
|
+
target=_drain, args=(process.stdout, lines), daemon=True)
|
|
132
|
+
reader.start()
|
|
133
|
+
|
|
134
|
+
_send(process, _INITIALIZE_ID, "initialize", {
|
|
135
|
+
"protocolVersion": 1,
|
|
136
|
+
"clientCapabilities": {
|
|
137
|
+
"fs": {"readTextFile": False, "writeTextFile": False},
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
_send(process, _SESSION_NEW_ID, "session/new", {
|
|
141
|
+
"cwd": str(Path.cwd()),
|
|
142
|
+
"mcpServers": [],
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
result = _await_result(process, lines, _SESSION_NEW_ID)
|
|
146
|
+
finally:
|
|
147
|
+
process.kill()
|
|
148
|
+
|
|
149
|
+
available = (result.get("models") or {}).get("availableModels") or []
|
|
150
|
+
models = []
|
|
151
|
+
for entry in available:
|
|
152
|
+
if not isinstance(entry, dict):
|
|
153
|
+
continue
|
|
154
|
+
model_id = str(entry.get("modelId", "")).strip()
|
|
155
|
+
if not model_id:
|
|
156
|
+
continue
|
|
157
|
+
name = str(entry.get("name", "")).strip() or model_id
|
|
158
|
+
models.append(AgentModel(model_id, name))
|
|
159
|
+
log.debug("copilot reported %d model(s)", len(models))
|
|
160
|
+
return models
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _send(process: subprocess.Popen, request_id: int, method: str, params: dict) -> None:
|
|
164
|
+
request = {"jsonrpc": "2.0", "id": request_id,
|
|
165
|
+
"method": method, "params": params}
|
|
166
|
+
process.stdin.write(json.dumps(request) + "\n")
|
|
167
|
+
process.stdin.flush()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _drain(stream, lines: "queue.Queue[str]") -> None:
|
|
171
|
+
"""Pump the CLI's stdout into a queue so the read can be given a deadline."""
|
|
172
|
+
for line in stream:
|
|
173
|
+
lines.put(line)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _await_result(
|
|
177
|
+
process: subprocess.Popen, lines: "queue.Queue[str]", request_id: int
|
|
178
|
+
) -> dict:
|
|
179
|
+
"""Read until the response to ``request_id`` arrives, or the deadline passes.
|
|
180
|
+
|
|
181
|
+
Everything else on the wire — the initialize reply, the session/update
|
|
182
|
+
notifications the CLI starts emitting straight away — is skipped.
|
|
183
|
+
"""
|
|
184
|
+
deadline = time.monotonic() + ACP_TIMEOUT_SECONDS
|
|
185
|
+
while True:
|
|
186
|
+
if time.monotonic() >= deadline:
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
f"copilot --acp did not answer within {ACP_TIMEOUT_SECONDS}s")
|
|
189
|
+
try:
|
|
190
|
+
line = lines.get(timeout=0.5)
|
|
191
|
+
except queue.Empty:
|
|
192
|
+
# An immediate exit means the CLI is missing, unauthenticated, or
|
|
193
|
+
# too old for --acp; no point waiting out the whole deadline.
|
|
194
|
+
if process.poll() is not None:
|
|
195
|
+
raise RuntimeError(
|
|
196
|
+
f"copilot --acp exited {process.returncode}: "
|
|
197
|
+
f"{(process.stderr.read() or '').strip()[:300] or 'no output'}"
|
|
198
|
+
)
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
message = json.loads(line)
|
|
203
|
+
except json.JSONDecodeError:
|
|
204
|
+
continue
|
|
205
|
+
if not isinstance(message, dict) or message.get("id") != request_id:
|
|
206
|
+
continue
|
|
207
|
+
if "error" in message:
|
|
208
|
+
raise RuntimeError(f"copilot --acp errored: {message['error']}")
|
|
209
|
+
result = message.get("result")
|
|
210
|
+
return result if isinstance(result, dict) else {}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _parse_events(stdout: str) -> tuple[str, dict | None, list[str]]:
|
|
214
|
+
"""Pull the reply, the trailing result event and any errors out of the JSONL.
|
|
215
|
+
|
|
216
|
+
Returns ``("", None, [])`` for output that isn't the JSONL stream at all, so
|
|
217
|
+
the caller can tell "no result event" from "the run failed".
|
|
218
|
+
"""
|
|
219
|
+
reply = ""
|
|
220
|
+
outcome: dict | None = None
|
|
221
|
+
errors: list[str] = []
|
|
222
|
+
|
|
223
|
+
for line in stdout.splitlines():
|
|
224
|
+
line = line.strip()
|
|
225
|
+
if not line:
|
|
226
|
+
continue
|
|
227
|
+
try:
|
|
228
|
+
event = json.loads(line)
|
|
229
|
+
except json.JSONDecodeError:
|
|
230
|
+
continue
|
|
231
|
+
if not isinstance(event, dict):
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
kind = event.get("type")
|
|
235
|
+
if kind == ASSISTANT_MESSAGE:
|
|
236
|
+
# Keep the last message that actually said something: the ones in
|
|
237
|
+
# between carry only tool requests, and reasoning is a separate field.
|
|
238
|
+
content = (event.get("data") or {}).get("content") or ""
|
|
239
|
+
if content.strip():
|
|
240
|
+
reply = content.strip()
|
|
241
|
+
elif kind == SESSION_ERROR:
|
|
242
|
+
data = event.get("data") or {}
|
|
243
|
+
message = data.get("message") or data.get("errorType") or "unknown error"
|
|
244
|
+
errors.append(str(message))
|
|
245
|
+
elif kind == RESULT:
|
|
246
|
+
outcome = event
|
|
247
|
+
|
|
248
|
+
return reply, outcome, errors
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _detail(errors: list[str], stderr: str) -> str:
|
|
252
|
+
"""Best available explanation of a failure, trimmed for the log line."""
|
|
253
|
+
return ("; ".join(errors) or stderr.strip() or "no error detail")[:500]
|