kestrel-feature-github 0.1.0__tar.gz → 0.1.1__tar.gz

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.
Files changed (18) hide show
  1. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/.gitignore +1 -0
  2. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/PKG-INFO +1 -1
  3. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/kestrel_feature_github/feature.py +140 -47
  4. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/pyproject.toml +1 -1
  5. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/tests/test_github_feature.py +32 -2
  6. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/.github/workflows/ci.yml +0 -0
  7. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/.github/workflows/publish.yml +0 -0
  8. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/AGENTS.md +0 -0
  9. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/LICENSE +0 -0
  10. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/README.md +0 -0
  11. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/kestrel_feature_github/SKILL.md +0 -0
  12. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/kestrel_feature_github/__init__.py +0 -0
  13. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/kestrel_feature_github/ast_analyzer.py +0 -0
  14. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/kestrel_feature_github/cache.py +0 -0
  15. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/kestrel_feature_github/client.py +0 -0
  16. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/kestrel_feature_github/models.py +0 -0
  17. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/tests/__init__.py +0 -0
  18. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.1.1}/tests/conftest.py +0 -0
@@ -1,2 +1,3 @@
1
1
  __pycache__/
2
2
  *.pyc
3
+ agent_data/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kestrel-feature-github
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Kestrel GitHub integration feature
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -7,6 +7,7 @@ import yaml
7
7
 
8
8
  from kestrel_sdk.features.base import Feature, tool
9
9
  from kestrel_sdk.tools.base import ToolCategory
10
+ from kestrel_sdk.tools.result import ToolResult
10
11
 
11
12
  from .ast_analyzer import ASTAnalyzer
12
13
  from .cache import GitHubCache
@@ -90,7 +91,7 @@ class GitHubFeature(Feature):
90
91
  repo: str,
91
92
  path: str,
92
93
  ref: str = "main",
93
- ) -> str:
94
+ ) -> ToolResult:
94
95
  """Read a file from GitHub.
95
96
 
96
97
  Args:
@@ -108,16 +109,22 @@ class GitHubFeature(Feature):
108
109
  # Check cache first
109
110
  cached = await self.cache.get(repo, path, ref)
110
111
  if cached:
111
- return f"# {path} (cached)\n\n{cached.content}"
112
+ return ToolResult.ok(
113
+ f"# {path} (cached)\n\n{cached.content}",
114
+ data={"repo": repo, "path": path, "ref": ref, "cached": True},
115
+ )
112
116
 
113
117
  # Fetch from GitHub
114
118
  try:
115
119
  content = await self.client.get_file_content(repo, path, ref)
116
120
  # Cache it
117
121
  await self.cache.set(content)
118
- return f"# {path}\n\n{content.content}"
122
+ return ToolResult.ok(
123
+ f"# {path}\n\n{content.content}",
124
+ data={"repo": repo, "path": path, "ref": ref, "cached": False},
125
+ )
119
126
  except GitHubClientError as e:
120
- return f"Error reading {path}: {e}"
127
+ return ToolResult.failed(error=f"Error reading {path}: {e}")
121
128
 
122
129
  @tool(
123
130
  name="list_github_files",
@@ -130,7 +137,7 @@ class GitHubFeature(Feature):
130
137
  path: str = "",
131
138
  ref: str = "main",
132
139
  recursive: bool = False,
133
- ) -> str:
140
+ ) -> ToolResult:
134
141
  """List files in a directory.
135
142
 
136
143
  Args:
@@ -165,9 +172,12 @@ class GitHubFeature(Feature):
165
172
  size = f"{f.size:,}" if f.size else "?"
166
173
  lines.append(f"\U0001f4c4 {f.path} ({size} bytes)")
167
174
 
168
- return "\n".join(lines)
175
+ return ToolResult.ok(
176
+ "\n".join(lines),
177
+ data={"repo": repo, "path": path, "ref": ref, "count": len(files)},
178
+ )
169
179
  except GitHubClientError as e:
170
- return f"Error listing {path}: {e}"
180
+ return ToolResult.failed(error=f"Error listing {path}: {e}")
171
181
 
172
182
  @tool(
173
183
  name="search_github_code",
@@ -181,7 +191,7 @@ class GitHubFeature(Feature):
181
191
  path: Optional[str] = None,
182
192
  extension: Optional[str] = None,
183
193
  max_results: int = 20,
184
- ) -> str:
194
+ ) -> ToolResult:
185
195
  """Search for code in GitHub.
186
196
 
187
197
  Args:
@@ -203,7 +213,10 @@ class GitHubFeature(Feature):
203
213
  )
204
214
 
205
215
  if not results:
206
- return f"No results found for: {query}"
216
+ return ToolResult.ok(
217
+ f"No results found for: {query}",
218
+ data={"query": query, "count": 0},
219
+ )
207
220
 
208
221
  lines = [f"# Search results for: {query}\n"]
209
222
 
@@ -217,9 +230,12 @@ class GitHubFeature(Feature):
217
230
  if fragment:
218
231
  lines.append(f"\n```\n{fragment}\n```")
219
232
 
220
- return "\n".join(lines)
233
+ return ToolResult.ok(
234
+ "\n".join(lines),
235
+ data={"query": query, "count": len(results)},
236
+ )
221
237
  except GitHubClientError as e:
222
- return f"Search error: {e}"
238
+ return ToolResult.failed(error=f"Search error: {e}")
223
239
 
224
240
  @tool(
225
241
  name="get_code_definition",
@@ -232,7 +248,7 @@ class GitHubFeature(Feature):
232
248
  path: str,
233
249
  name: str,
234
250
  ref: str = "main",
235
- ) -> str:
251
+ ) -> ToolResult:
236
252
  """Get a specific function or class definition.
237
253
 
238
254
  Args:
@@ -249,7 +265,7 @@ class GitHubFeature(Feature):
249
265
  ref = GITHUB_DEFAULT_BRANCH
250
266
 
251
267
  if not path.endswith(".py"):
252
- return "Error: AST analysis only supports Python files (.py)"
268
+ return ToolResult.failed(error="AST analysis only supports Python files (.py)")
253
269
 
254
270
  # Get file content
255
271
  try:
@@ -261,7 +277,7 @@ class GitHubFeature(Feature):
261
277
  await self.cache.set(file_content)
262
278
  content = file_content.content
263
279
  except GitHubClientError as e:
264
- return f"Error reading {path}: {e}"
280
+ return ToolResult.failed(error=f"Error reading {path}: {e}")
265
281
 
266
282
  # Parse and find definition
267
283
  analyzer = ASTAnalyzer(content, path)
@@ -271,9 +287,13 @@ class GitHubFeature(Feature):
271
287
  # List available definitions
272
288
  all_defs = analyzer.get_definitions()
273
289
  available = [d.name for d in all_defs[:20]]
274
- return f"Definition '{name}' not found in {path}.\n\nAvailable: {', '.join(available)}"
290
+ return ToolResult.ok(
291
+ f"Definition '{name}' not found in {path}.\n\nAvailable: {', '.join(available)}",
292
+ data={"repo": repo, "path": path, "name": name, "found": False, "available": available},
293
+ )
275
294
 
276
- return f"""# {defn.type.title()}: {defn.name}
295
+ return ToolResult.ok(
296
+ f"""# {defn.type.title()}: {defn.name}
277
297
 
278
298
  **File:** {path}
279
299
  **Lines:** {defn.start_line}-{defn.end_line}
@@ -285,7 +305,17 @@ class GitHubFeature(Feature):
285
305
  ## Source
286
306
  ```python
287
307
  {defn.source}
288
- ```"""
308
+ ```""",
309
+ data={
310
+ "repo": repo,
311
+ "path": path,
312
+ "name": defn.name,
313
+ "type": defn.type,
314
+ "start_line": defn.start_line,
315
+ "end_line": defn.end_line,
316
+ "found": True,
317
+ },
318
+ )
289
319
 
290
320
  @tool(
291
321
  name="list_code_definitions",
@@ -297,7 +327,7 @@ class GitHubFeature(Feature):
297
327
  repo: str,
298
328
  path: str,
299
329
  ref: str = "main",
300
- ) -> str:
330
+ ) -> ToolResult:
301
331
  """List all definitions in a Python file.
302
332
 
303
333
  Args:
@@ -313,7 +343,7 @@ class GitHubFeature(Feature):
313
343
  ref = GITHUB_DEFAULT_BRANCH
314
344
 
315
345
  if not path.endswith(".py"):
316
- return "Error: AST analysis only supports Python files (.py)"
346
+ return ToolResult.failed(error="AST analysis only supports Python files (.py)")
317
347
 
318
348
  # Get file content
319
349
  try:
@@ -325,14 +355,17 @@ class GitHubFeature(Feature):
325
355
  await self.cache.set(file_content)
326
356
  content = file_content.content
327
357
  except GitHubClientError as e:
328
- return f"Error reading {path}: {e}"
358
+ return ToolResult.failed(error=f"Error reading {path}: {e}")
329
359
 
330
360
  # Parse
331
361
  analyzer = ASTAnalyzer(content, path)
332
362
  definitions = analyzer.get_definitions()
333
363
 
334
364
  if not definitions:
335
- return f"No function or class definitions found in {path}"
365
+ return ToolResult.ok(
366
+ f"No function or class definitions found in {path}",
367
+ data={"repo": repo, "path": path, "count": 0},
368
+ )
336
369
 
337
370
  lines = [f"# Definitions in {path}\n"]
338
371
 
@@ -358,14 +391,24 @@ class GitHubFeature(Feature):
358
391
  if len(methods) > 30:
359
392
  lines.append(f" ... and {len(methods) - 30} more")
360
393
 
361
- return "\n".join(lines)
394
+ return ToolResult.ok(
395
+ "\n".join(lines),
396
+ data={
397
+ "repo": repo,
398
+ "path": path,
399
+ "count": len(definitions),
400
+ "classes": len(classes),
401
+ "functions": len(functions),
402
+ "methods": len(methods),
403
+ },
404
+ )
362
405
 
363
406
  @tool(
364
407
  name="get_self_repo_info",
365
408
  description="Get information about the agent's own source repository.",
366
409
  category=ToolCategory.DATA_ACCESS,
367
410
  )
368
- async def get_self_repo_info(self) -> str:
411
+ async def get_self_repo_info(self) -> ToolResult:
369
412
  """Get info about the agent's own repository.
370
413
 
371
414
  Returns:
@@ -376,7 +419,8 @@ class GitHubFeature(Feature):
376
419
  try:
377
420
  info = await self.client.get_repo_info(repo)
378
421
 
379
- return f"""# Agent Source Repository
422
+ return ToolResult.ok(
423
+ f"""# Agent Source Repository
380
424
 
381
425
  **Repository:** {info.get('full_name')}
382
426
  **Description:** {info.get('description', 'N/A')}
@@ -392,16 +436,24 @@ class GitHubFeature(Feature):
392
436
  - Open Issues: {info.get('open_issues_count', 0)}
393
437
  - Last Updated: {info.get('updated_at', 'unknown')}
394
438
 
395
- Use `list_source_components` to see the feature components that make up this agent."""
439
+ Use `list_source_components` to see the feature components that make up this agent.""",
440
+ data={
441
+ "repo": info.get("full_name", repo),
442
+ "default_branch": info.get("default_branch"),
443
+ "visibility": info.get("visibility"),
444
+ "open_issues_count": info.get("open_issues_count"),
445
+ "url": info.get("html_url"),
446
+ },
447
+ )
396
448
  except GitHubClientError as e:
397
- return f"Error getting repo info: {e}"
449
+ return ToolResult.failed(error=f"Error getting repo info: {e}")
398
450
 
399
451
  @tool(
400
452
  name="list_source_components",
401
453
  description="List all feature components in the agent's source code with their manifests.",
402
454
  category=ToolCategory.DATA_ACCESS,
403
455
  )
404
- async def list_source_components(self, include_files: bool = False) -> str:
456
+ async def list_source_components(self, include_files: bool = False) -> ToolResult:
405
457
  """List all feature components.
406
458
 
407
459
  Args:
@@ -417,7 +469,7 @@ Use `list_source_components` to see the feature components that make up this age
417
469
  try:
418
470
  files = await self.client.list_directory(repo, GITHUB_SELF_FEATURES_ROOT, ref)
419
471
  except GitHubClientError as e:
420
- return f"Could not access features directory: {e}"
472
+ return ToolResult.failed(error=f"Could not access features directory: {e}")
421
473
 
422
474
  components = []
423
475
 
@@ -477,7 +529,10 @@ Use `list_source_components` to see the feature components that make up this age
477
529
  if len(comp["files"]) > 20:
478
530
  lines.append(f" ... and {len(comp['files']) - 20} more")
479
531
 
480
- return "\n".join(lines)
532
+ return ToolResult.ok(
533
+ "\n".join(lines),
534
+ data={"repo": repo, "component_count": len(components)},
535
+ )
481
536
 
482
537
  @tool(
483
538
  name="get_component_source",
@@ -488,7 +543,7 @@ Use `list_source_components` to see the feature components that make up this age
488
543
  self,
489
544
  component: str,
490
545
  include_content: bool = False,
491
- ) -> str:
546
+ ) -> ToolResult:
492
547
  """Get source files for a component.
493
548
 
494
549
  Args:
@@ -511,10 +566,13 @@ Use `list_source_components` to see the feature components that make up this age
511
566
  if f.path.startswith(component_path + "/") and f.is_file()
512
567
  ]
513
568
  except GitHubClientError as e:
514
- return f"Could not access component '{component}': {e}"
569
+ return ToolResult.failed(error=f"Could not access component '{component}': {e}")
515
570
 
516
571
  if not comp_files:
517
- return f"Component '{component}' not found or has no files"
572
+ return ToolResult.ok(
573
+ f"Component '{component}' not found or has no files",
574
+ data={"component": component, "file_count": 0},
575
+ )
518
576
 
519
577
  lines = [f"# Component: {component}\n"]
520
578
  lines.append(f"**Path:** {component_path}")
@@ -546,7 +604,10 @@ Use `list_source_components` to see the feature components that make up this age
546
604
  else:
547
605
  lines.append(f"*Size: {f.size:,} bytes*")
548
606
 
549
- return "\n".join(lines)
607
+ return ToolResult.ok(
608
+ "\n".join(lines),
609
+ data={"component": component, "path": component_path, "file_count": len(comp_files)},
610
+ )
550
611
 
551
612
  @tool(
552
613
  name="invalidate_github_cache",
@@ -557,7 +618,7 @@ Use `list_source_components` to see the feature components that make up this age
557
618
  self,
558
619
  repo: str,
559
620
  path: Optional[str] = None,
560
- ) -> str:
621
+ ) -> ToolResult:
561
622
  """Invalidate cache entries.
562
623
 
563
624
  Args:
@@ -572,8 +633,14 @@ Use `list_source_components` to see the feature components that make up this age
572
633
  await self.cache.invalidate(repo, path=path)
573
634
 
574
635
  if path:
575
- return f"Invalidated cache for {repo}:{path}"
576
- return f"Invalidated all cache for {repo}"
636
+ return ToolResult.ok(
637
+ f"Invalidated cache for {repo}:{path}",
638
+ data={"repo": repo, "path": path},
639
+ )
640
+ return ToolResult.ok(
641
+ f"Invalidated all cache for {repo}",
642
+ data={"repo": repo, "path": None},
643
+ )
577
644
 
578
645
  # --- Issue tools ---
579
646
 
@@ -588,7 +655,7 @@ Use `list_source_components` to see the feature components that make up this age
588
655
  state: str = "open",
589
656
  labels: Optional[str] = None,
590
657
  max_results: int = 30,
591
- ) -> str:
658
+ ) -> ToolResult:
592
659
  """List issues in a repository.
593
660
 
594
661
  Args:
@@ -608,10 +675,13 @@ Use `list_source_components` to see the feature components that make up this age
608
675
  repo, state=state, labels=label_list, per_page=max_results,
609
676
  )
610
677
  except GitHubClientError as e:
611
- return f"Could not list issues: {e}"
678
+ return ToolResult.failed(error=f"Could not list issues: {e}")
612
679
 
613
680
  if not issues:
614
- return f"No {state} issues found in {repo}"
681
+ return ToolResult.ok(
682
+ f"No {state} issues found in {repo}",
683
+ data={"repo": repo, "state": state, "count": 0},
684
+ )
615
685
 
616
686
  lines = [f"# Issues in {repo} ({state})\n"]
617
687
  for issue in issues:
@@ -630,7 +700,10 @@ Use `list_source_components` to see the feature components that make up this age
630
700
  lines.append(line)
631
701
 
632
702
  lines.append(f"\n*{len(issues)} issue(s) shown*")
633
- return "\n".join(lines)
703
+ return ToolResult.ok(
704
+ "\n".join(lines),
705
+ data={"repo": repo, "state": state, "count": len(issues)},
706
+ )
634
707
 
635
708
  @tool(
636
709
  name="get_github_issue",
@@ -641,7 +714,7 @@ Use `list_source_components` to see the feature components that make up this age
641
714
  self,
642
715
  issue_number: int,
643
716
  repo: str = "self",
644
- ) -> str:
717
+ ) -> ToolResult:
645
718
  """Get a specific issue.
646
719
 
647
720
  Args:
@@ -656,7 +729,7 @@ Use `list_source_components` to see the feature components that make up this age
656
729
  try:
657
730
  issue = await self.client.get_issue(repo, issue_number)
658
731
  except GitHubClientError as e:
659
- return f"Could not get issue #{issue_number}: {e}"
732
+ return ToolResult.failed(error=f"Could not get issue #{issue_number}: {e}")
660
733
 
661
734
  title = issue.get("title", "")
662
735
  state = issue.get("state", "")
@@ -685,7 +758,21 @@ Use `list_source_components` to see the feature components that make up this age
685
758
  lines.append(f"**Comments:** {comments_count}")
686
759
  lines.append(f"\n---\n\n{body}")
687
760
 
688
- return "\n".join(lines)
761
+ return ToolResult.ok(
762
+ "\n".join(lines),
763
+ data={
764
+ "repo": repo,
765
+ "number": issue_number,
766
+ "title": title,
767
+ "state": state,
768
+ "author": author,
769
+ "labels": issue_labels,
770
+ "assignees": assignees,
771
+ "milestone": milestone_name,
772
+ "comments_count": comments_count,
773
+ "url": issue.get("html_url"),
774
+ },
775
+ )
689
776
 
690
777
  @tool(
691
778
  name="get_github_issue_comments",
@@ -697,7 +784,7 @@ Use `list_source_components` to see the feature components that make up this age
697
784
  issue_number: int,
698
785
  repo: str = "self",
699
786
  max_results: int = 30,
700
- ) -> str:
787
+ ) -> ToolResult:
701
788
  """Get comments on an issue.
702
789
 
703
790
  Args:
@@ -715,10 +802,13 @@ Use `list_source_components` to see the feature components that make up this age
715
802
  repo, issue_number, per_page=max_results,
716
803
  )
717
804
  except GitHubClientError as e:
718
- return f"Could not get comments for issue #{issue_number}: {e}"
805
+ return ToolResult.failed(error=f"Could not get comments for issue #{issue_number}: {e}")
719
806
 
720
807
  if not comments:
721
- return f"No comments on issue #{issue_number} in {repo}"
808
+ return ToolResult.ok(
809
+ f"No comments on issue #{issue_number} in {repo}",
810
+ data={"repo": repo, "number": issue_number, "count": 0},
811
+ )
722
812
 
723
813
  lines = [f"# Comments on #{issue_number} in {repo}\n"]
724
814
  for comment in comments:
@@ -731,4 +821,7 @@ Use `list_source_components` to see the feature components that make up this age
731
821
  lines.append("")
732
822
 
733
823
  lines.append(f"\n*{len(comments)} comment(s)*")
734
- return "\n".join(lines)
824
+ return ToolResult.ok(
825
+ "\n".join(lines),
826
+ data={"repo": repo, "number": issue_number, "count": len(comments)},
827
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kestrel-feature-github"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "Kestrel GitHub integration feature"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -320,7 +320,8 @@ class TestGitHubFeature:
320
320
 
321
321
  result = await feature.read_github_file(repo="self", path="test.py")
322
322
 
323
- assert "cached content" in result
323
+ assert "cached content" in result.confirmation
324
+ assert result.data["cached"] is True
324
325
 
325
326
  @pytest.mark.asyncio
326
327
  async def test_get_definition_non_python(self, feature):
@@ -330,4 +331,33 @@ class TestGitHubFeature:
330
331
  name="something"
331
332
  )
332
333
 
333
- assert "only supports Python" in result
334
+ assert "only supports Python" in result.error
335
+
336
+
337
+ # ============== Contract Tests ==============
338
+
339
+ def test_all_tool_methods_return_toolresult():
340
+ """Guard against regressing to str returns: the GitHubFeature module is on
341
+ the sovereign migrated-feature allowlist, so every @tool method MUST be
342
+ annotated ``-> ToolResult`` or registration hard-fails in the host."""
343
+ import typing
344
+
345
+ from kestrel_sdk.tools.result import ToolResult
346
+
347
+ tool_methods = [
348
+ "read_github_file",
349
+ "list_github_files",
350
+ "search_github_code",
351
+ "get_code_definition",
352
+ "list_code_definitions",
353
+ "get_self_repo_info",
354
+ "list_source_components",
355
+ "get_component_source",
356
+ "invalidate_github_cache",
357
+ "list_github_issues",
358
+ "get_github_issue",
359
+ "get_github_issue_comments",
360
+ ]
361
+ for name in tool_methods:
362
+ hints = typing.get_type_hints(getattr(GitHubFeature, name))
363
+ assert hints.get("return") is ToolResult, f"{name} must return ToolResult"